Compare commits

..

1 Commits

Author SHA1 Message Date
Brendan Allan 54d89e2f6d test(app): route e2e mocks through HttpApi 2026-08-17 23:41:07 +00:00
842 changed files with 27649 additions and 21265 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
+52
View File
@@ -0,0 +1,52 @@
name: generate
on:
push:
branches:
- dev
- v2
jobs:
generate:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Generate
run: ./script/generate.ts
- name: Commit and push
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git add -A
git commit -m "chore: generate" --allow-empty
git push origin HEAD:${{ github.ref_name }} --no-verify
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
# echo ""
# echo "============================================"
# echo "Failed to push generated code."
# echo "Please run locally and push:"
# echo ""
# echo " ./script/generate.ts"
# echo " git add -A && git commit -m \"chore: generate\" && git push"
# echo ""
# echo "============================================"
# exit 1
# fi
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [dev, beta, v2] branches: [dev, beta]
paths: paths:
- "bun.lock" - "bun.lock"
- "package.json" - "package.json"
+1 -3
View File
@@ -82,7 +82,6 @@ jobs:
build-cli: build-cli:
needs: version needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode'
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -197,7 +196,7 @@ jobs:
build-node-cli: build-node-cli:
needs: version needs: version
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled if: github.repository == 'anomalyco/opencode'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -595,7 +594,6 @@ jobs:
path: packages/cli/dist path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.build-node-cli.result == 'success'
with: with:
pattern: opencode-node-cli-* pattern: opencode-node-cli-*
path: packages/cli/dist/node path: packages/cli/dist/node
-4
View File
@@ -80,8 +80,6 @@ jobs:
if: always() if: always()
timeout-minutes: 10 timeout-minutes: 10
working-directory: packages/cli working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: | run: |
bun run script/build.ts --single --skip-install bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts bun run script/service-smoke.ts
@@ -96,8 +94,6 @@ jobs:
if: always() if: always()
timeout-minutes: 15 timeout-minutes: 15
working-directory: packages/cli working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: | run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node bun run script/service-smoke.ts --node
-231
View File
@@ -1,231 +0,0 @@
---
name: opencode-dev
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Heap snapshots
The CLI installs a `SIGUSR1` listener on non-Windows processes in `packages/cli/src/heap.ts`. Use it to capture the installed `opencode2` server without restarting it or attaching an inspector.
1. Find the processes and inspect their roles and memory:
```bash
pgrep -a -f 'opencode2\.exe|opencode2'
ps -o pid,ppid,rss,vsz,lstart,etime,cmd -p <pid>,<pid>
```
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode2.exe serve --service` child, not the short wrapper/TUI process:
```bash
kill -USR1 <server-pid>
```
3. Wait for `heap snapshot written` in the channel's log before opening the file. Snapshots are written to the same log directory as `heap-<pid>-<timestamp>.heapsnapshot`; writing a large heap can take several seconds and the file is incomplete until the completion message appears:
```bash
grep 'heap snapshot' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'heap-<server-pid>-*.heapsnapshot' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` instead for a local/dev channel process. The log's `path=` field is authoritative.
4. Analyze the snapshot with Chrome DevTools, a V8 heap snapshot parser, or a temporary tool installed outside the repository. Start with the largest retained objects, dominators, object counts grouped by constructor/name, and retainer paths back to GC roots. Relate suspicious names and paths back to the source tree rather than treating large shallow allocations as leaks.
For command-line analysis, install tooling under `/tmp/opencode`, not in the repository. For example, MemLab can rank dominators and trace a reported heap object ID back to a GC root:
```bash
npm install --prefix /tmp/opencode/heap-analysis @memlab/cli
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze object-size --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze shape --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab trace --snapshot <snapshot> --node-id=<id>
```
A single snapshot explains what retains memory at one point in time, but does not by itself prove a leak. For leak confirmation, capture a baseline, perform a controlled repeated workload, allow idle cleanup/GC when possible, capture another snapshot, and compare growth and retainer paths. Also compare snapshot heap size with process RSS: a large difference can indicate native allocations, database mappings, allocator fragmentation, or other memory outside the JavaScript heap.
```bash
cat /proc/<pid>/smaps_rollup
pmap -x <pid> | sort -k3 -nr | head -25
```
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## CPU profiles
The CLI installs a `SIGPROF` listener on non-Windows processes in `packages/cli/src/cpu-profile.ts`. One signal starts a ten-second CPU profile and stops it automatically; additional signals are ignored while a profile is active. There is no CPU profile CLI flag or environment variable.
1. Get the PID from the health endpoint. For shared-service performance, target the server PID returned here rather than the short wrapper or TUI process:
```bash
opencode2 api get /api/health
```
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
2. Start the capture:
```bash
kill -PROF <server-pid>
```
3. Wait for `CPU profile written` in the channel's log before opening the file. Profiles are written to the same log directory as `cpu-<pid>-<timestamp>.cpuprofile`; the log's `path=` field is authoritative:
```bash
grep 'CPU profile' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'cpu-<server-pid>-*.cpuprofile' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` for a local/dev process. Load the completed `.cpuprofile` in Chrome DevTools or another V8 CPU profile viewer and inspect the hottest functions, call stacks, and self time during the controlled workload.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until a debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
+51 -61
View File
@@ -28,6 +28,7 @@
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2", "turbo": "2.10.2",
"vitest": "4.1.10",
}, },
}, },
"packages/ai": { "packages/ai": {
@@ -62,6 +63,7 @@
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -119,7 +121,6 @@
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "1.2.1", "@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -590,9 +591,9 @@
}, },
"peerDependencies": { "peerDependencies": {
"@opencode-ai/theme": "workspace:*", "@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.4", "@opentui/core": ">=0.5.3",
"@opentui/keymap": ">=0.5.4", "@opentui/keymap": ">=0.5.3",
"@opentui/solid": ">=0.5.4", "@opentui/solid": ">=0.5.3",
"solid-js": ">=1.9.0", "solid-js": ">=1.9.0",
}, },
"optionalPeers": [ "optionalPeers": [
@@ -685,8 +686,9 @@
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "file:../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:", "@shikijs/stream": "catalog:",
"@solid-primitives/event-listener": "2.4.5", "@solid-primitives/event-listener": "2.4.5",
@@ -1088,9 +1090,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.4", "@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.4", "@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.4", "@opentui/solid": "0.5.3",
"@pierre/diffs": "1.2.10", "@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -2112,27 +2114,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="], "@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="], "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="], "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="], "@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="], "@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3230,7 +3232,7 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
@@ -3240,7 +3242,7 @@
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
@@ -3542,7 +3544,7 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
@@ -3946,7 +3948,7 @@
"es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="], "es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
@@ -5618,7 +5620,7 @@
"tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="], "tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -6052,8 +6054,6 @@
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="], "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/cloudflare/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "@astrojs/cloudflare/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"@astrojs/cloudflare/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], "@astrojs/cloudflare/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
@@ -6070,6 +6070,8 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="], "@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="],
@@ -6368,6 +6370,8 @@
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
@@ -6466,6 +6470,8 @@
"@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"@solidjs/start/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], "@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
@@ -6536,12 +6542,6 @@
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.127", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.31", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SiD2kyR2J4fEY0FTXYpNznwWN90ohrwzAMi2laZCGkI/lL8DmyJ31zFzMYJvzRusp2IQYmvkYQWOpZpBl01xYw=="], "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.127", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.31", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SiD2kyR2J4fEY0FTXYpNznwWN90ohrwzAMi2laZCGkI/lL8DmyJ31zFzMYJvzRusp2IQYmvkYQWOpZpBl01xYw=="],
@@ -6580,10 +6580,14 @@
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
"astro/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"astro/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "astro/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"astro/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "astro/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"astro/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"astro/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "astro/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
@@ -6874,6 +6878,10 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], "sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -6924,15 +6932,11 @@
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"vite-plugin-dynamic-import/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], "vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], "vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
@@ -7024,8 +7028,6 @@
"@astrojs/node/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@astrojs/node/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/node/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/node/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "@astrojs/node/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@astrojs/node/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "@astrojs/node/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7044,8 +7046,6 @@
"@astrojs/node/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "@astrojs/node/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/node/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/node/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "@astrojs/node/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/node/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "@astrojs/node/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7082,8 +7082,6 @@
"@astrojs/vercel/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@astrojs/vercel/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/vercel/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/vercel/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "@astrojs/vercel/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
"@astrojs/vercel/astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="], "@astrojs/vercel/astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
@@ -7100,8 +7098,6 @@
"@astrojs/vercel/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "@astrojs/vercel/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/vercel/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/vercel/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "@astrojs/vercel/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/vercel/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "@astrojs/vercel/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7460,8 +7456,6 @@
"@opencode-ai/www/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@opencode-ai/www/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@opencode-ai/www/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@opencode-ai/www/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "@opencode-ai/www/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode-ai/www/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "@opencode-ai/www/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7480,8 +7474,6 @@
"@opencode-ai/www/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "@opencode-ai/www/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@opencode-ai/www/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@opencode-ai/www/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "@opencode-ai/www/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@opencode-ai/www/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "@opencode-ai/www/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7590,8 +7582,6 @@
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -7656,8 +7646,6 @@
"blume/@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "blume/@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"blume/@astrojs/mdx/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "blume/@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], "blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
@@ -7688,8 +7676,6 @@
"blume/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "blume/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"blume/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "blume/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"blume/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "blume/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7702,8 +7688,6 @@
"blume/astro/p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="], "blume/astro/p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="],
"blume/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"blume/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], "blume/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"blume/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "blume/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7840,6 +7824,12 @@
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -7912,8 +7902,6 @@
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="], "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
@@ -8796,6 +8784,8 @@
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=", "x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=", "aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=", "aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo=" "x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
} }
} }
+2 -2
View File
@@ -10,7 +10,7 @@
]).nodeModules.${stdenvNoCC.hostPlatform.system}, ]).nodeModules.${stdenvNoCC.hostPlatform.system},
}: }:
let let
packageJson = lib.pipe ../packages/cli/package.json [ packageJson = lib.pipe ../packages/opencode/package.json [
builtins.readFile builtins.readFile
builtins.fromJSON builtins.fromJSON
]; ];
@@ -52,7 +52,7 @@ stdenvNoCC.mkDerivation {
--cpu="${bunCpu}" \ --cpu="${bunCpu}" \
--os="${bunOs}" \ --os="${bunOs}" \
--filter '!./' \ --filter '!./' \
--filter './packages/cli' \ --filter './packages/opencode' \
--filter './packages/desktop' \ --filter './packages/desktop' \
--filter './packages/app' \ --filter './packages/app' \
--frozen-lockfile \ --frozen-lockfile \
+10 -8
View File
@@ -48,13 +48,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
env.OPENCODE_DISABLE_MODELS_FETCH = true; env.OPENCODE_DISABLE_MODELS_FETCH = true;
env.OPENCODE_VERSION = finalAttrs.version; env.OPENCODE_VERSION = finalAttrs.version;
env.OPENCODE_CHANNEL = "prod"; env.OPENCODE_CHANNEL = "prod";
env.NODE_OPTIONS = "--max-old-space-size=4096";
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
cd ./packages/cli cd ./packages/opencode
bun --bun ./script/build.ts --single --skip-install bun --bun ./script/build.ts --single --skip-install
bun --bun ./script/schema.ts schema.json
runHook postBuild runHook postBuild
''; '';
@@ -62,9 +62,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2 install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
wrapProgram $out/bin/opencode2 \ wrapProgram $out/bin/opencode \
--prefix PATH : ${ --prefix PATH : ${
lib.makeBinPath ( lib.makeBinPath (
[ [
@@ -80,9 +81,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions # trick yargs into also generating zsh completions
installShellCompletion --cmd opencode2 \ installShellCompletion --cmd opencode \
--bash <($out/bin/opencode2 completion) \ --bash <($out/bin/opencode completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion) --zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
''; '';
nativeInstallCheckInputs = [ nativeInstallCheckInputs = [
@@ -94,6 +95,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
versionCheckProgramArg = "--version"; versionCheckProgramArg = "--version";
passthru = { passthru = {
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
env = finalAttrs.env; env = finalAttrs.env;
}; };
@@ -101,7 +103,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
description = "The open source coding agent"; description = "The open source coding agent";
homepage = "https://opencode.ai"; homepage = "https://opencode.ai";
license = lib.licenses.mit; license = lib.licenses.mit;
mainProgram = "opencode2"; mainProgram = "opencode";
inherit (node_modules.meta) platforms; inherit (node_modules.meta) platforms;
}; };
}) })
+5 -4
View File
@@ -47,9 +47,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.4", "@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.4", "@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.4", "@opentui/solid": "0.5.3",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -121,7 +121,8 @@
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2" "turbo": "2.10.2",
"vitest": "4.1.10"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
+1 -1
View File
@@ -193,7 +193,7 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only. `LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation: Native chronological system messages are route/model-specific. Anthropic Messages lowers them natively for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
```text ```text
<system-update> <system-update>
@@ -437,19 +437,10 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem) return yield* Effect.forEach(content, lowerToolResultContentItem)
}) })
// Mid-conversation system messages became available with Opus 4.8 and version // Mid-conversation system messages are a native Claude API feature only for
// 5 of the other supported Claude families. Treat later family versions as // Opus 4.8. Other Anthropic models intentionally use the same visible wrapped-
// compatible without assuming that every Anthropic Messages model is Claude. // user fallback as non-Anthropic routes rather than sending a role they reject.
const supportsNativeSystemUpdates = (request: LLMRequest) => { const supportsNativeSystemUpdates = (request: LLMRequest) => String(request.model.id) === "claude-opus-4-8"
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
String(request.model.id).toLowerCase(),
)
if (!match) return false
const major = Number(match[2])
if (match[1] !== "opus") return major >= 5
if (major !== 4) return major >= 5
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
}
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => { const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1) const last = message.content.at(-1)
@@ -905,7 +896,6 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "input_json_delta" && event.index !== undefined) { if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting( const result = ToolStream.appendExisting(
ADAPTER, ADAPTER,
state.tools, state.tools,
@@ -967,12 +957,9 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
] ]
} }
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) { const onMessageStop = (state: ParserState): StepResult => {
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle const lifecycle = Lifecycle.finish(state.lifecycle, events, {
events.push(...result.events)
const finished = Lifecycle.finish(lifecycle, events, {
reason: state.pendingFinish?.reason ?? { reason: state.pendingFinish?.reason ?? {
normalized: "unknown", normalized: "unknown",
raw: undefined, raw: undefined,
@@ -980,8 +967,8 @@ const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (st
usage: state.usage, usage: state.usage,
providerMetadata: state.pendingFinish?.providerMetadata, providerMetadata: state.pendingFinish?.providerMetadata,
}) })
return [{ ...state, lifecycle: finished, tools: result.tools }, events] satisfies StepResult return [{ ...state, lifecycle }, events]
}) }
// Prefix `error.type` so overloads, rate limits, and quota errors are visible // Prefix `error.type` so overloads, rate limits, and quota errors are visible
// even when the provider message is generic or empty. // even when the provider message is generic or empty.
@@ -1005,7 +992,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event) if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event) if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return onMessageStop(state) if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
if (event.type === "error") return onError(event) if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS]) return Effect.succeed<StepResult>([state, NO_EVENTS])
} }
+9 -29
View File
@@ -90,7 +90,6 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
export const InputItem = Schema.Union([ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({ Schema.Struct({
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
@@ -141,11 +140,6 @@ export const Tool = Schema.Struct({
export const ToolChoice = Schema.Union([ export const ToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]), Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({
type: Schema.tag("allowed_tools"),
mode: Schema.Literals(["auto", "none", "required"]),
tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })),
}),
]) ])
// Fields shared between the HTTP body and the WebSocket `response.create` // Fields shared between the HTTP body and the WebSocket `response.create`
@@ -159,7 +153,6 @@ export const coreFields = {
tools: optionalArray(Tool), tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice), tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema), service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String), prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema), include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
@@ -175,8 +168,6 @@ export const coreFields = {
}), }),
), ),
max_output_tokens: Schema.optional(Schema.Number), max_output_tokens: Schema.optional(Schema.Number),
max_tool_calls: Schema.optional(Schema.Int),
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Number),
} }
@@ -448,10 +439,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const message of request.messages) { for (const message of request.messages) {
if (message.role === "system") { if (message.role === "system") {
input.push({ const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
role: "developer", const previous = input.at(-1)
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)), if (previous && "role" in previous && previous.role === "user")
}) input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
}
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
continue continue
} }
@@ -585,19 +580,6 @@ const lowerOptions = (request: LLMRequest) => {
: {}), : {}),
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}), ...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}), ...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
type: "allowed_tools" as const,
mode: allowed.mode,
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
} }
} }
@@ -620,9 +602,7 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
), ),
), ),
tool_choice: tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined,
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const, stream: true as const,
max_output_tokens: generation?.maxTokens, max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature, temperature: generation?.temperature,
@@ -121,8 +121,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
: yield* Effect.forEach(request.tools, (tool) => : yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
), ),
tool_choice: tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
} satisfies OpenAIResponsesBody } satisfies OpenAIResponsesBody
}) })
@@ -1,4 +1,4 @@
import { Option, Schema } from "effect" import { Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema/index.js" import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
export const ResponseIncludables = [ export const ResponseIncludables = [
@@ -11,62 +11,52 @@ export const ResponseIncludables = [
"reasoning.encrypted_content", "reasoning.encrypted_content",
"message.output_text.logprobs", "message.output_text.logprobs",
] as const ] as const
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {}) export type ResponseIncludable = (typeof ResponseIncludables)[number]
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number] export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
export type Truncation = (typeof Truncations)[number] const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
export const ReasoningEffort = Schema.String export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>( export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ServiceTierSchema = Schema.Literals(ServiceTiers) export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const TruncationSchema = Schema.Literals(Truncations)
export const AllowedTools = Schema.Struct({ export interface Resolved {
toolNames: Schema.Array(Schema.String), readonly instructions?: string
mode: Schema.optional(Schema.Literals(["auto", "none", "required"])), readonly store?: boolean
}) readonly reasoningEffort?: string
export type AllowedTools = typeof AllowedTools.Type readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
export const Options = Schema.Struct({ readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
instructions: Schema.optional(Schema.String), readonly serviceTier?: ServiceTier
store: Schema.optional(Schema.Boolean),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
} }
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => { export const resolve = (request: LLMRequest): Resolved => {
const input = Option.getOrUndefined( const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
decodeOptions(request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]), const include = Array.isArray(input?.include)
) ? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
if (!input) return {} : []
const reasoningSummary = input?.reasoningSummary
return { return {
...input, instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
include: input.include?.length ? input.include : undefined, store: typeof input?.store === "boolean" ? input.store : undefined,
allowedTools: reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
input.allowedTools && input.allowedTools.toolNames.length > 0 reasoningSummary:
? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" } reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
? reasoningSummary
: undefined, : undefined,
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
} }
} }
@@ -1,7 +1,16 @@
import type { Options } from "../protocols/utils/open-responses-options.js" import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions } from "../schema/index.js" import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown } export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
}
export type OpenResponsesProviderOptionsInput = ProviderOptions & { export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput readonly openresponses?: OpenResponsesOptionsInput
@@ -19,7 +19,6 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly baseURL: string readonly baseURL: string
readonly provider?: string readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -76,7 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits, limits: settings.limits,
provider: settings.provider, provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
export const baseten = define(profiles.baseten) export const baseten = define(profiles.baseten)
+3 -13
View File
@@ -7,7 +7,6 @@ import {
type FinishReasonDetails, type FinishReasonDetails,
type AIError, type AIError,
type LLMRequest, type LLMRequest,
type ProviderMetadata,
type UsageInput, type UsageInput,
} from "./schema/index.js" } from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect" import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
@@ -34,22 +33,13 @@ export interface LayerOptions {
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = ( export const complete = (
options: { options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
readonly reason: FinishReasonDetails
readonly usage?: UsageInput
readonly providerMetadata?: ProviderMetadata
},
...events: readonly LLMEvent[] ...events: readonly LLMEvent[]
) => [ ) => [
LLMEvent.stepStart({ index: 0 }), LLMEvent.stepStart({ index: 0 }),
...events, ...events,
LLMEvent.stepFinish({ LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
index: 0, LLMEvent.finish({ reason: options.reason }),
reason: options.reason,
usage: options.usage,
providerMetadata: options.providerMetadata,
}),
LLMEvent.finish({ reason: options.reason, providerMetadata: options.providerMetadata }),
] ]
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events) export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
@@ -136,33 +136,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("supports native chronological system updates on documented and later Claude family versions", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-8",
"claude-opus-5-1",
"claude-sonnet-5",
"claude-haiku-5-1",
"claude-fable-6",
"anthropic/claude-mythos-7.2",
]
const prepared = yield* Effect.forEach(ids, (id) =>
compileRequest(
LLM.request({
model: AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id }),
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
cache: "none",
}),
),
)
expect(prepared.map((item) => item.body.messages[1]?.role)).toEqual(ids.map(() => "system"))
}),
)
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () => it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -190,34 +163,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("does not infer native system update support for older or undocumented Claude families", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-7",
"claude-opus-4-20250514",
"claude-sonnet-4-9",
"claude-haiku-4-9",
"custom-model-7",
]
const prepared = yield* Effect.forEach(ids, (id) =>
compileRequest(
LLM.request({
model: AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id }),
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
cache: "none",
}),
),
)
expect(prepared.map((item) => item.body.messages.some((message) => message.role === "system"))).toEqual(
ids.map(() => false),
)
}),
)
it.effect("rejects non-text chronological system update content before send", () => it.effect("rejects non-text chronological system update content before send", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* compileRequest(
@@ -1010,65 +955,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("ignores tool input deltas without a matching tool start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.toolCalls).toEqual([])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("assembles and persists multiple tool calls from one Anthropic response", () => it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js" import { LLM, LLMEvent, Message } from "../../src/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js" import { configure } from "../../src/providers/openai-compatible-responses.js"
import { OpenAI } from "../../src/providers.js" import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js" import { OpenResponses } from "../../src/protocols/open-responses.js"
@@ -56,28 +56,6 @@ describe("Open Responses-compatible route", () => {
}), }),
) )
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
it.effect("rejects OpenAI-native tools", () => it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = configure({ const model = configure({
@@ -123,36 +101,13 @@ describe("Open Responses-compatible route", () => {
const model = configure({ const model = configure({
apiKey: "test-key", apiKey: "test-key",
baseURL: "https://responses.example.test/v1", baseURL: "https://responses.example.test/v1",
providerOptions: { providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
openresponses: {
reasoningEffort: "low",
store: true,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
},
},
}).model("example-model") }).model("example-model")
const prepared = yield* compileRequest( const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
LLM.request({
model,
prompt: "Think.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
reasoning: { effort: "low" }, reasoning: { effort: "low" },
store: true, store: true,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
mode: "auto",
tools: [{ type: "function", name: "lookup" }],
},
max_tool_calls: 2,
parallel_tool_calls: false,
}) })
}), }),
) )
@@ -241,18 +241,27 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("lowers chronological system updates to developer messages in order", () => it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model, model,
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")], messages: [
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
}), }),
) )
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] }, {
{ role: "developer", content: "Operator update." }, role: "user",
content: [
{ type: "input_text", text: "Before." },
{ type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
],
},
{ role: "assistant", content: [{ type: "output_text", text: "After." }] }, { role: "assistant", content: [{ type: "output_text", text: "After." }] },
]) ])
}), }),
@@ -1274,20 +1283,11 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think", prompt: "think",
promptCacheKey: "session_123", promptCacheKey: "session_123",
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
],
toolChoice: "none",
providerOptions: { providerOptions: {
openai: { openai: {
reasoningEffort: "high", reasoningEffort: "high",
reasoningSummary: "auto", reasoningSummary: "auto",
include: ["reasoning.encrypted_content"], include: ["reasoning.encrypted_content"],
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
parallelToolCalls: false,
}, },
}, },
}), }),
@@ -1298,17 +1298,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" }) expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: "read" },
{ type: "function", name: "grep" },
],
})
expect(prepared.body.max_tool_calls).toBe(4)
expect(prepared.body.parallel_tool_calls).toBe(false)
}), }),
) )
@@ -1334,17 +1323,20 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("passes forward-compatible includable values through", () => it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model, model,
prompt: "hi", prompt: "hi",
// The user passed one invalid entry alongside a valid one. Keep the
// valid one so the request still succeeds rather than failing on a
// typo from upstream config.
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } }, providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
}), }),
) )
expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"]) expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}), }),
) )
@@ -1358,13 +1350,13 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("passes an unknown includable value through", () => it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
) )
expect(prepared.body.include).toEqual(["bogus.thing"]) expect(prepared.body.include).toBeUndefined()
}), }),
) )
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import type { import type {
JsonValue, JsonValue,
OpenCodeEvent, OpenCodeEvent,
@@ -114,7 +114,6 @@ const nextOrdinals = new Map<string, { text: number; reasoning: number }>()
const startedParts = new Set<string>() const startedParts = new Set<string>()
const toolStates = new Map<string, ToolStatus>() const toolStates = new Map<string, ToolStatus>()
let eventSequence = 0 let eventSequence = 0
let durableSequence = -1
export async function setupTimeline( export async function setupTimeline(
page: Page, page: Page,
@@ -133,8 +132,6 @@ export async function setupTimeline(
seedHistory?: boolean seedHistory?: boolean
} = {}, } = {},
) { ) {
eventSequence = 0
durableSequence = -1
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
const messages = const messages =
input.sessionMessages ?? input.sessionMessages ??
@@ -430,23 +427,9 @@ export function messageUpdated(info: SessionMessageAssistant) {
} }
export function status(type: SessionStatus["type"], attempt = 1) { export function status(type: SessionStatus["type"], attempt = 1) {
if (type === "busy") return makeEvent("session.execution.started", { sessionID }) return event("session.status", {
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID })
return makeEvent("session.retry.scheduled", {
sessionID, sessionID,
assistantMessageID: assistantID, status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
attempt,
at: 1700000010000,
error: { type: "provider.error", message: "Rate limited" },
})
}
export function stepStarted(message: SessionMessageAssistant) {
return makeEvent("session.step.started", {
sessionID,
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
}) })
} }
@@ -820,7 +803,7 @@ function makeEvent<Type extends OpenCodeEvent["type"]>(
definition.durability === "durable" definition.durability === "durable"
? { ? {
...base, ...base,
durable: { aggregateID: sessionID, seq: ++durableSequence, version: definition.durable.version }, durable: { aggregateID: sessionID, seq: eventSequence, version: definition.durable.version },
} }
: base : base
return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent
@@ -25,7 +25,7 @@ async function installSessionSwitchProbe(
let running = true let running = true
const reviewLevels: Record<string, string> = { const reviewLevels: Record<string, string> = {
panel: "#review-panel", panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]', tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]', body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]', review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]', preview: '#review-panel [data-slot="session-review-v2-preview"]',
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture, pageMessages } from "./session-timeline-stress.fixture" import { fixture, pageMessages } from "./session-timeline-stress.fixture"
@@ -5,9 +5,11 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => { test("applies message latency after a list response gate is released", async () => {
const events: string[] = [] const events: string[] = []
const gate = Promise.withResolvers<void>() const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined let handler: ((route: Route) => Promise<void>) | undefined
const page = { const page = {
addInitScript: () => Promise.resolve(), addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => { route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback handler = callback
return Promise.resolve() return Promise.resolve()
@@ -21,6 +23,7 @@ test("applies message latency after a list response gate is released", async ()
messageDelay: 25, messageDelay: 25,
beforeMessagesResponse: () => { beforeMessagesResponse: () => {
events.push("before") events.push("before")
started.resolve()
return gate.promise return gate.promise
}, },
onMessages: (request) => events.push(request.phase), onMessages: (request) => events.push(request.phase),
@@ -31,12 +34,18 @@ test("applies message latency after a list response gate is released", async ()
}) })
const response = handler!({ const response = handler!({
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }), request: () => ({
url: () => "http://127.0.0.1:4096/api/session/session/message",
method: () => "GET",
headers: () => ({}),
postDataBuffer: () => null,
}),
fulfill: () => { fulfill: () => {
events.push("fulfill") events.push("fulfill")
return Promise.resolve() return Promise.resolve()
}, },
} as unknown as Route) } as unknown as Route)
await started.promise
expect(events).toEqual(["start", "before"]) expect(events).toEqual(["start", "before"])
const released = performance.now() const released = performance.now()
@@ -45,3 +54,42 @@ test("applies message latency after a list response gate is released", async ()
expect(performance.now() - released).toBeGreaterThanOrEqual(20) expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"]) expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
}) })
test("routes requests through the HttpApi contract", async () => {
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
onConnectKey: connected.resolve,
})
const body = Buffer.from(JSON.stringify({ key: "secret" }))
let status: number | undefined
await handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
method: () => "POST",
headers: () => ({ "content-type": "application/json" }),
postDataBuffer: () => body,
}),
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
status = response?.status
return Promise.resolve()
},
} as unknown as Route)
expect(status).toBe(204)
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
})
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture } from "../timeline/session-timeline-stress.fixture" import { fixture } from "../timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../timeline/timeline-test-helpers" import { stressSessionHref } from "../timeline/timeline-test-helpers"
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
@@ -38,6 +38,12 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
expect(
requests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
}),
).toBe(true)
}) })
function session(id: string, directory: string, title: string) { function session(id: string, directory: string, title: string) {
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits" import { expectAppVisible } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits" import { expectAppVisible } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
@@ -52,7 +52,7 @@ test("shows a pending question dock", async ({ page }) => {
rejectRequests.push(request.url()) rejectRequests.push(request.url())
}) })
await question.getByRole("button", { name: "Minimize question" }).click() await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible() await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible() await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByText("Select one answer")).toBeHidden() await expect(question.getByText("Select one answer")).toBeHidden()
@@ -63,7 +63,7 @@ test("shows a pending question dock", async ({ page }) => {
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0) await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
expect(rejectRequests).toEqual([]) expect(rejectRequests).toEqual([])
await question.getByRole("button", { name: "Restore question" }).click() await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible() await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible() await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible() await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
@@ -93,9 +93,7 @@ test.describe("regression: session timeline local row state", () => {
events.push(...textEvents()) events.push(...textEvents())
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({ await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
timeout: 10_000,
})
expect(await readToolState(page)).toEqual({ expect(await readToolState(page)).toEqual({
expanded: false, expanded: false,
@@ -121,9 +119,7 @@ test.describe("regression: session timeline local row state", () => {
events.push(...textEvents()) events.push(...textEvents())
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({ await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
timeout: 10_000,
})
const siblingProbe = await readDiffProbe(page) const siblingProbe = await readDiffProbe(page)
expect(siblingProbe).toEqual({ expect(siblingProbe).toEqual({
fileMarker: "before", fileMarker: "before",
@@ -236,7 +232,7 @@ async function readToolState(page: Page) {
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"), row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`), streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}), }),
`${assistantMessageID}:text:0`, textPartID,
) )
} }
@@ -313,7 +309,7 @@ function toolContent(part: typeof editPart): SessionMessageAssistant["content"][
} }
} }
let eventSequence = -1 let eventSequence = 0
function textEvents(): OpenCodeEvent[] { function textEvents(): OpenCodeEvent[] {
return [ return [
@@ -414,7 +410,6 @@ async function mockServer(
events: EventPayload[], events: EventPayload[],
messages: SessionMessageInfo[] = [userMessage, assistantMessage], messages: SessionMessageInfo[] = [userMessage, assistantMessage],
) { ) {
eventSequence = -1
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: project(), project: project(),
@@ -310,10 +310,45 @@ function toolContent(part: ContextTool): SessionMessageAssistant["content"][numb
} }
} }
let eventSequence = -1 let eventSequence = 0
function toolEvents(part: ContextTool): OpenCodeEvent[] { function toolEvents(part: ContextTool): OpenCodeEvent[] {
const events = [
eventValue(
"session.tool.input.started",
{
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
name: part.tool,
},
1,
),
eventValue(
"session.tool.input.ended",
{
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
text: JSON.stringify(part.state.input),
},
1,
),
eventValue(
"session.tool.called",
{
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
input: part.state.input,
executed: true,
},
1,
),
] satisfies OpenCodeEvent[]
if (part.state.status === "running") return events
return [ return [
...events,
eventValue( eventValue(
"session.tool.success", "session.tool.success",
{ {
@@ -346,7 +381,6 @@ function eventValue<Type extends OpenCodeEvent["type"]>(
} }
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) { async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
eventSequence = -1
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: project(), project: project(),
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import type { SessionMessageAssistant } from "@opencode-ai/client/promise" import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { import {
@@ -9,7 +9,6 @@ import {
setupTimeline, setupTimeline,
shell, shell,
status, status,
stepStarted,
textPart, textPart,
userMessage, userMessage,
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
@@ -99,12 +98,12 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await timeline.send(status("retry"), 180) await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(stepStarted(assistant), 180) await timeline.send(status("busy", 2), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 350) await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText( await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
"Recovered response", "Recovered response",
@@ -203,12 +203,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'), page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true") ).toHaveAttribute("data-active", "true")
await timeline.transport.send({ await timeline.send(event("session.status", { sessionID: backgroundID, status: { type: "idle" } }))
id: "evt_background_succeeded",
created: Date.now(),
type: "session.execution.succeeded",
data: { sessionID: backgroundID },
} as never)
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
await expect(backgroundCard).toContainText("Background task (background)") await expect(backgroundCard).toContainText("Background task (background)")
}) })
@@ -131,14 +131,15 @@ test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
"aria-label", "aria-label",
"sample-skill", "sample-skill",
) )
await expect( await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`), "aria-label",
).toHaveAttribute("aria-label", "OpenCode") "OpenCode",
)
for (const id of [pending, completed]) { for (const id of [pending, completed]) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`) const skill = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill") await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·") await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·")
await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible() await expect(skill.locator('use[href="#opencode-icon-post-skill"]')).toBeVisible()
} }
}) })
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
@@ -26,7 +26,7 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
}) })
test("keeps the parent visible while the child session resolves", async ({ page }) => { test("keeps the parent visible while child lineage resolves", async ({ page }) => {
await setup(page) await setup(page)
const requested = Promise.withResolvers<void>() const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>() const release = Promise.withResolvers<void>()
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096" const server = "http://127.0.0.1:4096"
@@ -1,4 +1,4 @@
import { base64Encode, checksum } from "@opencode-ai/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture" import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
@@ -477,7 +477,7 @@ async function configureSmokePage(page: Page, directory: string) {
} }
let recordFrame: number | undefined let recordFrame: number | undefined
const record = () => { const record = () => {
for (const toast of document.querySelectorAll<HTMLElement>(".toast-v2--error")) { for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
const text = toast.textContent?.trim() const text = toast.textContent?.trim()
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text) if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
} }
+218
View File
@@ -0,0 +1,218 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
const Json = Schema.Unknown.pipe(HttpApiSchema.asJson())
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
search: Schema.optional(Schema.String),
order: Schema.optional(Schema.String),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
path: Schema.optional(Schema.String),
query: Schema.optional(Schema.String),
type: Schema.optional(Schema.String),
})
const SessionParams = { sessionID: Schema.String }
const NoContent = HttpApiSchema.NoContent
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
message: Schema.String,
}) {}
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
message: Schema.String,
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
.add(
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
params: { integrationID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
params: { integrationID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
)
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
.add(
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionList", "/api/session", {
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
.add(
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
params: SessionParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
params: { ...SessionParams, formID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
params: { ...SessionParams, permissionID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
params: { ...SessionParams, messageID: Schema.String },
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
params: SessionParams,
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
export const MockApi = HttpApi.make("mock").add(Group)
+299 -313
View File
@@ -1,5 +1,9 @@
import type { Page, Route } from "@playwright/test" import type { Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Duration, Effect, Layer } from "effect"
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig { export interface MockServerConfig {
provider: unknown | (() => unknown) provider: unknown | (() => unknown)
@@ -38,9 +42,8 @@ type MockStreamWindow = Window & {
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>() const state = { cursors: new Map<string, string>(), nextCursor: 0 }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
let nextCursor = 0
await page.addInitScript( await page.addInitScript(
({ server, retry }) => { ({ server, retry }) => {
@@ -127,307 +130,311 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50) }, 50)
page.on("close", () => clearInterval(timer)) page.on("close", () => clearInterval(timer))
} }
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
page.on("close", () => void transport.dispose())
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const appPort = new URL( const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port ).port
if (url.origin !== server && url.port !== appPort) return route.fallback() if (url.origin !== server && url.port !== appPort) return route.fallback()
if (route.request().method() === "OPTIONS") {
const path = url.pathname return route.fulfill({ status: 204, headers: corsHeaders })
if (path === "/api/event") {
const events = config.events?.()
return sse(
route,
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
config.eventRetry,
)
} }
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/api/reference") const body = route.request().postDataBuffer()
return json(route, { const response = await transport.handler(
location: { new Request(url, {
directory: config.directory, method: route.request().method(),
project: { headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
if (response.status === 404 && url.origin !== server) return route.fallback()
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
const events = config.events?.()
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
})
.handleRaw("fsRead", (ctx) =>
Effect.gen(function* () {
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
}),
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
reference: () =>
Effect.succeed({
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
},
data: [],
}),
agent: () =>
Effect.succeed({
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
integrationGet: (ctx) =>
Effect.succeed({
location: location(config),
data: {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
connections: [],
},
}),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
projectList: () => {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id, id: (config.project as { id?: string }).id,
directory: config.directory, directory: config.directory,
canonical: config.directory, canonical: config.directory,
}, }),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
]),
worktreeCreate: (ctx) => {
const input = record(ctx.payload) ? ctx.payload : {}
return Effect.succeed({
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
typeof input.name === "string" ? input.name : "copy"
}`,
})
}, },
data: [], worktreeRemove: () => noContent,
}) worktreeRefresh: () => noContent,
if (path === "/api/agent") location: () => Effect.succeed(location(config)),
return json(route, { permissionRequests: () =>
location: location(config), Effect.succeed({
data: [ location: location(config),
{ data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
id: "build", currentPermission,
name: "Build", ),
mode: "primary", }),
hidden: false, formRequests: () =>
request: { settings: {}, headers: {}, body: {} }, Effect.succeed({
permissions: [], location: location(config),
}, data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
], }),
}) vcs: () =>
if (path === "/api/provider") Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
return json(route, { vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
location: location(config), vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
data: currentProviders(providerConfig(config)), fsList: (ctx) =>
}) Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
if (path === "/api/model") Effect.map((data) => ({ location: location(config), data })),
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: {
id: integration,
name: integration,
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
connections: [],
},
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current")
return json(route, {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
})
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
const entries = await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("type") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})
}
if (path === "/api/shell" && route.request().method() === "GET")
return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (path === "/api/session") {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
)
config.sessions.push(created)
return json(route, { data: created })
}
if (route.request().method() !== "GET") return route.fallback()
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => {
if (parentID === null) return true
if (parentID === "null") return session.parentID === undefined
return session.parentID === parentID
})
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
), ),
), fsFind: (ctx) =>
}) Effect.promise(() =>
} Promise.resolve(
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }),
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) ),
} ).pipe(
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1] Effect.map((entries) => ({
if (sessionForm && route.request().method() === "GET") { location: location(config),
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? []) data: Array.isArray(entries)
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) }) ? entries.map((entry) =>
} typeof entry === "string"
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") { ? {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) name: entry.split(/[\\/]/).at(-1) ?? entry,
} path: entry,
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST") absolute: `${config.directory}/${entry}`,
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) type: "directory",
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET") ignored: false,
return json(route, { data: [] }) }
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1] : entry,
if (sessionPermission && route.request().method() === "GET") { )
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []) : entries,
return json(route, { })),
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission), ),
}) shell: () => Effect.succeed({ location: location(config), data: [] }),
} ptyConnectToken: () =>
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }),
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) sessionList: (ctx) => {
} const sessions = config.sessions
if ( .filter((session) => {
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && const location = session.location as { directory?: string } | undefined
route.request().method() === "POST" return (
) { !ctx.query.directory ||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) location?.directory === ctx.query.directory ||
} session.directory === ctx.query.directory
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { )
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) })
} .filter((session) => {
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) if (ctx.query.parentID === undefined) return true
if (currentSessionMatch) { if (ctx.query.parentID === "null") return session.parentID === undefined
const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) return session.parentID === ctx.query.parentID
if (!session) return json(route, { error: "Session not found" }, undefined, 404) })
return json(route, { .filter((session) =>
data: currentSession(session, config.directory), ctx.query.search === undefined
}) ? true
} : String(session.title ?? "")
.toLowerCase()
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/) .includes(ctx.query.search.toLowerCase()),
if (messageMatch) { )
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed()
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) const offset = Number(ctx.query.cursor ?? 0)
const message = const limit = ctx.query.limit ?? 50
config.message?.(messageMatch[1]!, messageMatch[2]!) ?? const data = ordered.slice(offset, offset + limit)
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2]) return Effect.succeed({
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) data: data.map((session) => currentSession(session, config.directory)),
return json(route, { data: message }) cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
} })
},
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) sessionCreate: (ctx) => {
if (messagesMatch) { const payload = record(ctx.payload) ? ctx.payload : {}
const token = url.searchParams.get("cursor") ?? undefined const created = currentSession(
const before = token ? cursors.get(token) : undefined {
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) id: "ses_mock_created",
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) projectID: (config.project as { id?: string }).id,
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) title: typeof payload.title === "string" ? payload.title : "New session",
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) },
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) config.directory,
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined )
if (cursor) cursors.set(cursor, pageData.cursor!) return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created }))
return json(route, { },
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(), sessionActive: () => {
cursor: { next: cursor }, const statuses = (
}) typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
} ) as Record<string, { type?: string }>
return Effect.succeed({
if (url.port === targetPort && targetPort !== appPort) data: Object.fromEntries(
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404) Object.entries(statuses).flatMap(([id, status]) =>
return route.fallback() status.type === "idle" ? [] : [[id, { type: "running" }]],
}) ),
),
})
},
sessionGet: (ctx) => {
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
return session
? Effect.succeed({ data: currentSession(session, config.directory) })
: Effect.fail(new MockNotFound({ message: "Session not found" }))
},
sessionRemove: () => noContent,
sessionShell: () => noContent,
sessionForm: (ctx) => {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return Effect.succeed({
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID),
})
},
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionPermissionReply: () => noContent,
sessionRename: () => noContent,
sessionInterrupt: () => noContent,
sessionRevertClear: () => noContent,
sessionRevertCommit: () => noContent,
messageGet: (ctx) =>
Effect.gen(function* () {
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })
yield* delay
const message =
config.message?.(ctx.params.sessionID, ctx.params.messageID) ??
config
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER)
.items.find((item) => item.id === ctx.params.messageID)
if (!message) return yield* new MockNotFound({ message: "Message not found" })
return { data: message }
}),
messageList: (ctx) => {
const token = ctx.query.cursor
const before = token ? state.cursors.get(token) : undefined
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" }))
return Effect.gen(function* () {
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" })
if (config.beforeMessagesResponse) {
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before }))
}
yield* delay
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before)
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined
if (cursor) state.cursors.set(cursor, pageData.cursor!)
return {
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
}
})
},
}),
)
} }
function location(config: MockServerConfig) { function location(config: MockServerConfig) {
@@ -585,24 +592,3 @@ function jsonValue(value: unknown): JsonValue | undefined {
function record(value: unknown): value is Record<string, unknown> { function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value) return !!value && typeof value === "object" && !Array.isArray(value)
} }
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
+1 -1
View File
@@ -148,7 +148,7 @@ export async function installSseTransport<T extends OpenCodeEvent = OpenCodeEven
})) }))
encoded.forEach((item) => marker(item.delivery.options?.marker)) encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) { if (input.burst) {
const bytes = encoder.encode(encoded.map((item) => new TextDecoder().decode(item.bytes)).join("")) const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
connection.controller.enqueue(bytes) connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
export const APP_READY_TIMEOUT = 30_000 export const APP_READY_TIMEOUT = 30_000
+1
View File
@@ -56,6 +56,7 @@
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
+23 -18
View File
@@ -34,9 +34,9 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServersProvider } from "@/context/servers" import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { LocationProvider } from "@/context/location" import { SDKProvider } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import { SessionUIProvider } from "@/pages/directory-layout" import { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { requireServerKey } from "./utils/session-route" import { requireServerKey } from "./utils/session-route"
@@ -48,16 +48,19 @@ import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
function TargetServerRoute(props: ParentProps) { function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>() const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal() const global = useGlobal()
const conn = createMemo(() => const conn = createMemo(() => {
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)), const key = requireServerKey(params.serverKey)
) return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
return ( return (
// Owns the server-identity remount. Session changes must not remount this subtree. // Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={conn()} keyed> <Show when={requireServerKey(params.serverKey)} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>} <Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
</Show> </Show>
) )
} }
@@ -66,12 +69,14 @@ function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show <Show when={tabs.ready()}>
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)} <Show
keyed when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
fallback={tabs.ready() && <Navigate href="/" />} keyed
> fallback={<Navigate href="/" />}
{(draft) => <ResolvedDraftRoute draft={draft} />} >
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
</Show> </Show>
) )
} }
@@ -86,13 +91,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
{(conn) => ( {(conn) => (
<ServerProvider conn={conn}> <ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}> <ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}> <SDKProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}> <DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders> <DraftProviders>
<NewSession draftId={props.draft.draftID} /> <NewSession draftId={props.draft.draftID} />
</DraftProviders> </DraftProviders>
</SessionUIProvider> </DirectoryDataProvider>
</LocationProvider> </SDKProvider>
</ModelsProvider> </ModelsProvider>
</ServerProvider> </ServerProvider>
)} )}
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import type { Project } from "@/types" import type { Project } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
+19 -2
View File
@@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -101,8 +102,16 @@ function Cell(props: {
</div> </div>
) )
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return ( return (
<Tooltip appearance={props.inline ? "compact" : "standard"} value={props.tip} placement="top"> <Tooltip value={props.tip} placement="top">
{content()} {content()}
</Tooltip> </Tooltip>
) )
@@ -142,8 +151,16 @@ function ToggleCell(props: {
</button> </button>
) )
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return ( return (
<Tooltip appearance={props.inline ? "compact" : "standard"} value={props.tip} placement="top"> <Tooltip value={props.tip} placement="top">
{content()} {content()}
</Tooltip> </Tooltip>
) )
@@ -1,10 +1,10 @@
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/dialog" import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Keybind } from "@opencode-ai/ui/keybind" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInput } from "@opencode-ai/ui/text-input" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
@@ -191,7 +191,7 @@ function CommandPaletteView(props: {
<Dialog class="command-palette-v2" size="large"> <Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body"> <DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search"> <div class="command-palette-v2-search">
<TextInput <TextInputV2
value={query()} value={query()}
autofocus autofocus
autocomplete="off" autocomplete="off"
@@ -295,7 +295,7 @@ function PaletteRow(props: {
</div> </div>
</div> </div>
<Show when={props.item.keybind}> <Show when={props.item.keybind}>
<Keybind keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" /> <KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show> </Show>
</Match> </Match>
<Match when={props.item.type === "session"}> <Match when={props.item.type === "session"}>
@@ -13,7 +13,7 @@ function ConnectProviderDialogStory() {
onMount(open) onMount(open)
return ( return (
<Button variant="neutral" onClick={open}> <Button variant="secondary" onClick={open}>
Open connect provider dialog Open connect provider dialog
</Button> </Button>
) )
@@ -29,7 +29,7 @@ function ProviderConnectionDialogStory(props) {
onMount(open) onMount(open)
return ( return (
<Button variant="neutral" onClick={open}> <Button variant="secondary" onClick={open}>
Open {props.provider} connection dialog Open {props.provider} connection dialog
</Button> </Button>
) )
@@ -5,13 +5,15 @@ import { List } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInput } from "@opencode-ai/ui/text-input" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations" import { useIntegrations } from "@/hooks/use-integrations"
@@ -74,7 +76,7 @@ export const DialogConnectProvider: Component<{
} }
return ( return (
<Dialog <DialogV2
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]" containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]" class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
> >
@@ -98,7 +100,7 @@ export const DialogConnectProvider: Component<{
<Content /> <Content />
</div> </div>
</DialogBody> </DialogBody>
</Dialog> </DialogV2>
) )
} }
@@ -167,7 +169,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
return ( return (
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}> <div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
<div class="shrink-0 px-1 pt-px"> <div class="shrink-0 px-1 pt-px">
<TextInput <TextInputV2
ref={search} ref={search}
type="search" type="search"
class="!w-full [font-family:var(--v2-font-family-sans)]" class="!w-full [font-family:var(--v2-font-family-sans)]"
@@ -256,6 +258,7 @@ function ProviderConnection(props: {
setBack: (handler: () => void) => void setBack: (handler: () => void) => void
}) { }) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync()
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory)
@@ -276,7 +279,11 @@ function ProviderConnection(props: {
}) })
const provider = createMemo(() => ({ const provider = createMemo(() => ({
id: props.provider, id: props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider, name:
providers.all().get(props.provider)?.name ??
serverSync.data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
})) }))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
@@ -378,7 +385,7 @@ function ProviderConnection(props: {
setFormStore("value", field.key, value) setFormStore("value", field.key, value)
}} }}
/> />
<Button class="w-auto" type="submit" size="large" variant="contrast" disabled={!valid()}> <Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")} {language.t("common.continue")}
</Button> </Button>
</Match> </Match>
@@ -515,7 +522,7 @@ function ProviderConnection(props: {
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base"> <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })} {language.t("provider.connect.apiKey.label", { provider: provider().name })}
<TextInput <TextInputV2
ref={apiKey} ref={apiKey}
class="!w-full" class="!w-full"
name="apiKey" name="apiKey"
@@ -536,9 +543,9 @@ function ProviderConnection(props: {
</div> </div>
)} )}
</Show> </Show>
<Button type="submit" variant="contrast" data-action="provider-connect-submit"> <ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
{language.t("common.continue")} {language.t("common.continue")}
</Button> </ButtonV2>
</form> </form>
</div> </div>
) )
@@ -584,7 +591,7 @@ function ProviderConnection(props: {
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base"> <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })} {language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
<TextInput <TextInputV2
ref={codeInput} ref={codeInput}
class="!w-full" class="!w-full"
name="code" name="code"
@@ -604,9 +611,9 @@ function ProviderConnection(props: {
</div> </div>
)} )}
</Show> </Show>
<Button type="submit" variant="contrast"> <ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")} {language.t("common.continue")}
</Button> </ButtonV2>
</form> </form>
</div> </div>
) )
@@ -1,7 +1,6 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
@@ -10,7 +9,7 @@ import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
import { useData } from "@/context/server" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
@@ -22,28 +21,27 @@ export function DialogCustomProvider(props: Props) {
const language = useLanguage() const language = useLanguage()
return ( return (
<Dialog class="h-full"> <Dialog
<DialogHeader> class="h-full"
<DialogTitle> title={
<IconButton <IconButton
tabIndex={-1} tabIndex={-1}
icon={<Icon name="arrow-left" />} icon="arrow-left"
variant="ghost" variant="ghost"
onClick={props.onBack} onClick={props.onBack}
aria-label={language.t("common.goBack")} aria-label={language.t("common.goBack")}
/> />
</DialogTitle> }
</DialogHeader> transition
<DialogBody> >
<CustomProviderForm /> <CustomProviderForm />
</DialogBody>
</Dialog> </Dialog>
) )
} }
export function CustomProviderForm(props: { autofocus?: boolean } = {}) { export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog() const dialog = useDialog()
const data = useData() const serverSync = useServerSync()
const language = useLanguage() const language = useLanguage()
const [form, setForm] = createStore<FormState>({ const [form, setForm] = createStore<FormState>({
@@ -118,9 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const output = validateCustomProvider({ const output = validateCustomProvider({
form, form,
t: language.t, t: language.t,
// TODO: Restore disabled-provider validation when V2 exposes config reads. disabledProviders: serverSync.data.config.disabled_providers ?? [],
disabledProviders: [], existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
}) })
batch(() => { batch(() => {
setForm("err", output.err) setForm("err", output.err)
@@ -240,7 +237,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
</div> </div>
<IconButton <IconButton
type="button" type="button"
icon={<Icon name="trash" />} icon="trash"
variant="ghost" variant="ghost"
class="mt-1.5" class="mt-1.5"
onClick={() => removeModel(i())} onClick={() => removeModel(i())}
@@ -284,7 +281,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
</div> </div>
<IconButton <IconButton
type="button" type="button"
icon={<Icon name="trash" />} icon="trash"
variant="ghost" variant="ghost"
class="mt-1.5" class="mt-1.5"
onClick={() => removeHeader(i())} onClick={() => removeHeader(i())}
@@ -303,7 +300,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
class="w-auto self-start" class="w-auto self-start"
type="submit" type="submit"
size="large" size="large"
variant="contrast" variant="primary"
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
> >
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")} {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
@@ -1,16 +1,17 @@
import { Button } from "@opencode-ai/ui/button" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/dialog" import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Field } from "@opencode-ai/ui/field" import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/project-avatar" import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { Tabs } from "@opencode-ai/ui/tabs" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Textarea } from "@opencode-ai/ui/textarea" import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInput } from "@opencode-ai/ui/text-input" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show, createSignal, startTransition } from "solid-js" import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/servers"
import { LocationProvider } from "@/context/location" import { SDKProvider } from "@/context/sdk"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project" import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions" import { ProjectSettingsExtensions } from "./project-settings-extensions"
@@ -21,9 +22,9 @@ import "./dialog-edit-project-v2.css"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
return ( return (
<SettingsServerDataScope server={props.server}> <SettingsServerDataScope server={props.server}>
<LocationProvider directory={props.project.worktree}> <SDKProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} /> <ProjectSettingsDialog project={props.project} server={props.server} />
</LocationProvider> </SDKProvider>
</SettingsServerDataScope> </SettingsServerDataScope>
) )
} }
@@ -36,46 +37,46 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
const Footer = () => ( const Footer = () => (
<DialogFooter> <DialogFooter>
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}> <ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </ButtonV2>
<Button type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}> <ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")} {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button> </ButtonV2>
</DialogFooter> </DialogFooter>
) )
return ( return (
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog"> <Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
<Tabs <TabsV2
orientation="vertical" orientation="vertical"
variant="settings" variant="settings"
value={tab()} value={tab()}
onChange={(value) => void startTransition(() => setTab(value))} onChange={(value) => void startTransition(() => setTab(value))}
class="project-settings-v2" class="project-settings-v2"
> >
<Tabs.List> <TabsV2.List>
<div class="project-settings-v2-nav"> <div class="project-settings-v2-nav">
<Tabs.Trigger value="general"> <TabsV2.Trigger value="general">
<ProjectAvatar <ProjectAvatar
fallback={projectName()} fallback={projectName()}
variant={getProjectAvatarVariant(props.project.icon?.color)} variant={getProjectAvatarVariant(props.project.icon?.color)}
class="!size-4 shrink-0" class="!size-4 shrink-0"
/> />
<span class="truncate">{projectName()}</span> <span class="truncate">{projectName()}</span>
</Tabs.Trigger> </TabsV2.Trigger>
<Tabs.Trigger value="scripts"> <TabsV2.Trigger value="scripts">
<Icon name="code" size="small" /> <Icon name="code" size="small" />
{language.t("project.settings.scripts")} {language.t("project.settings.scripts")}
</Tabs.Trigger> </TabsV2.Trigger>
<Tabs.Trigger value="extensions"> <TabsV2.Trigger value="extensions">
<Icon name="extensions" size="small" /> <Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")} {language.t("settings.tab.extensions")}
</Tabs.Trigger> </TabsV2.Trigger>
</div> </div>
</Tabs.List> </TabsV2.List>
<Tabs.Content value="general" class="project-settings-v2-panel"> <TabsV2.Content value="general" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form"> <form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll"> <div class="project-settings-v2-scroll">
<div class="project-settings-page-header"> <div class="project-settings-page-header">
@@ -85,7 +86,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Field> <Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label> <Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInput <TextInputV2
autofocus autofocus
appearance="large" appearance="large"
class="!w-full" class="!w-full"
@@ -131,7 +132,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
"opacity-0": !model.store.iconHover, "opacity-0": !model.store.iconHover,
}} }}
> >
<Icon name={model.store.iconOverride ? "close" : "share"} /> <IconV2 name={model.store.iconOverride ? "close" : "share"} />
</span> </span>
</button> </button>
<input <input
@@ -187,9 +188,9 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</div> </div>
<Footer /> <Footer />
</form> </form>
</Tabs.Content> </TabsV2.Content>
<Tabs.Content value="scripts" class="project-settings-v2-panel"> <TabsV2.Content value="scripts" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form"> <form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll"> <div class="project-settings-v2-scroll">
<div class="project-settings-page-header"> <div class="project-settings-page-header">
@@ -199,7 +200,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Field> <Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label> <Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix> <Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<Textarea <TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono" class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5} rows={5}
value={model.store.startup} value={model.store.startup}
@@ -211,12 +212,12 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</div> </div>
<Footer /> <Footer />
</form> </form>
</Tabs.Content> </TabsV2.Content>
<Tabs.Content value="extensions" class="project-settings-v2-panel"> <TabsV2.Content value="extensions" class="project-settings-v2-panel">
<ProjectSettingsExtensions /> <ProjectSettingsExtensions />
</Tabs.Content> </TabsV2.Content>
</Tabs> </TabsV2>
</Dialog> </Dialog>
) )
} }
+40 -54
View File
@@ -1,16 +1,16 @@
import { Component, createMemo } from "solid-js" import { Component, createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useData } from "@/context/server" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/util/encode"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route" import { sessionHref } from "@/utils/session-route"
@@ -27,9 +27,8 @@ function formatTime(date: Date): string {
export const DialogFork: Component = () => { export const DialogFork: Component = () => {
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const data = useData() const sync = useSync()
const serverSDK = useServerSDK() const sdk = useSDK()
const location = useWorkspaceLocation()
const prompt = usePrompt() const prompt = usePrompt()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -39,15 +38,19 @@ export const DialogFork: Component = () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return [] if (!sessionID) return []
const msgs = data.session.message.list(sessionID) const msgs = sync().data.message[sessionID] ?? []
const result: ForkableMessage[] = [] const result: ForkableMessage[] = []
for (const message of msgs) { for (const message of msgs) {
if (message.type !== "user" || !message.text) continue if (message.role !== "user") continue
const parts = sync().data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
result.push({ result.push({
id: message.id, id: message.id,
text: message.text.replace(/\n/g, " ").slice(0, 200), text: textPart.text.replace(/\n/g, " ").slice(0, 200),
time: formatTime(new Date(message.time.created)), time: formatTime(new Date(message.time.created)),
}) })
} }
@@ -60,31 +63,19 @@ export const DialogFork: Component = () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const message = data.session.message.get(sessionID, item.id)
if (message?.type !== "user") return const parts = sync().data.part[item.id] ?? []
const restored = extractPromptFromMessage(message, { const restored = extractPromptFromParts(parts, {
directory: location().directory, directory: sdk().directory,
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
const dir = base64Encode(location().directory) const dir = base64Encode(sdk().directory)
serverSDK.api.session sdk()
.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => { .then((forked) => {
data.session.remember(forked)
dialog.close() dialog.close()
const target = prompt.capture({ dir, id: forked.id }) prompt.set(restored, undefined, { dir, id: forked.id })
target.set(restored)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
navigate(sessionHref(server.key, forked.id)) navigate(sessionHref(server.key, forked.id))
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@@ -94,28 +85,23 @@ export const DialogFork: Component = () => {
} }
return ( return (
<Dialog> <Dialog title={language.t("command.session.fork")}>
<DialogHeader> <List
<DialogTitle>{language.t("command.session.fork")}</DialogTitle> class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
</DialogHeader> search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
<DialogBody> emptyMessage={language.t("dialog.fork.empty")}
<List key={(x) => x.id}
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0" items={messages}
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }} filterKeys={["text"]}
emptyMessage={language.t("dialog.fork.empty")} onSelect={handleSelect}
key={(x) => x.id} >
items={messages} {(item) => (
filterKeys={["text"]} <div class="w-full flex items-center gap-2">
onSelect={handleSelect} <span class="truncate flex-1 min-w-0 text-left font-normal">{item.text}</span>
> <span class="text-text-weak shrink-0 font-normal">{item.time}</span>
{(item) => ( </div>
<div class="w-full flex items-center gap-2"> )}
<span class="truncate flex-1 min-w-0 text-left font-normal">{item.text}</span> </List>
<span class="text-text-weak shrink-0 font-normal">{item.time}</span>
</div>
)}
</List>
</DialogBody>
</Dialog> </Dialog>
) )
} }
@@ -1,12 +1,15 @@
import { Button } from "@opencode-ai/ui/button" import { Dialog } from "@opencode-ai/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js" import { For, Show, type Component } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -41,80 +44,74 @@ export const DialogManageModels: Component = () => {
} }
return ( return (
<Dialog> <Dialog
<DialogHeader hideClose> title={language.t("dialog.model.manage")}
<DialogTitleGroup description={language.t("dialog.model.manage.description")}
title={language.t("dialog.model.manage")} action={
description={language.t("dialog.model.manage.description")}
/>
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={handleConnectProvider}> <Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={handleConnectProvider}>
{language.t("command.provider.connect")} {language.t("command.provider.connect")}
</Button> </Button>
</DialogHeader> }
<DialogBody> >
<List <List
class="px-3" class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`} key={(x) => `${x?.provider?.id}:${x?.id}`}
items={local.model.list()} items={local.model.list()}
filterKeys={["provider.name", "name", "id"]} filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.id} groupBy={(x) => x.provider.id}
groupHeader={(group) => { groupHeader={(group) => {
const provider = group.items[0].provider const provider = group.items[0].provider
return ( return (
<> <>
<span>{provider.name}</span> <span>{provider.name}</span>
<Tooltip <Tooltip
appearance="standard" placement="top"
placement="top" value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })} >
>
<Switch
appearance="standard"
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}}
onSelect={(x) => {
if (!x) return
const key = { modelID: x.id, providerID: x.provider.id }
local.model.setVisibility(key, !local.model.visible(key))
}}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch <Switch
appearance="standard" class="-mr-1"
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })} checked={providerVisible(provider.id)}
onChange={(checked) => { onChange={(checked) => setProviderVisibility(provider.id, checked)}
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) hideLabel
}} >
/> {provider.name}
</div> </Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}}
onSelect={(x) => {
if (!x) return
const key = { modelID: x.id, providerID: x.provider.id }
local.model.setVisibility(key, !local.model.visible(key))
}}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
/>
</div> </div>
)} </div>
</List> )}
</DialogBody> </List>
</Dialog> </Dialog>
) )
} }
@@ -157,20 +154,20 @@ export const DialogManageModelsV2: Component = () => {
}) })
return ( return (
<Dialog size="large" variant="settings" class="settings-v2-manage-models-dialog"> <DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}> <DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
<DialogTitleGroup <DialogTitleGroup
title={language.t("dialog.model.manage")} title={language.t("dialog.model.manage")}
description={language.t("dialog.model.manage.description")} description={language.t("dialog.model.manage.description")}
/> />
<Button variant="neutral" icon="plus" onClick={handleConnectProvider}> <ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
{language.t("command.provider.connect")} {language.t("command.provider.connect")}
</Button> </ButtonV2>
</DialogHeader> </DialogHeader>
<DialogBody class="flex min-h-0 flex-1 flex-col"> <DialogBody class="flex min-h-0 flex-1 flex-col">
<div class="px-4 pt-px pb-3"> <div class="px-4 pt-px pb-3">
<div class="relative"> <div class="relative">
<TextInput <TextInputV2
type="search" type="search"
appearance="base" appearance="base"
class="!w-full self-stretch" class="!w-full self-stretch"
@@ -185,12 +182,12 @@ export const DialogManageModelsV2: Component = () => {
aria-label={language.t("dialog.model.search.placeholder")} aria-label={language.t("dialog.model.search.placeholder")}
/> />
<Show when={list.filter()}> <Show when={list.filter()}>
<IconButton <IconButtonV2
type="button" type="button"
variant="ghost-muted" variant="ghost-muted"
size="small" size="small"
class="settings-v2-tab-search-clear" class="settings-v2-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />} icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()} onClick={() => list.clear()}
aria-label={language.t("common.clear")} aria-label={language.t("common.clear")}
/> />
@@ -228,14 +225,14 @@ export const DialogManageModelsV2: Component = () => {
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3> <h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
</div> </div>
<div> <div>
<Switch <SwitchV2
class="mr-6" class="mr-6"
checked={providerVisible(group.category)} checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)} onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel hideLabel
> >
{group.items[0].provider.name} {group.items[0].provider.name}
</Switch> </SwitchV2>
</div> </div>
</div> </div>
<SettingsListV2> <SettingsListV2>
@@ -243,13 +240,13 @@ export const DialogManageModelsV2: Component = () => {
{(item) => ( {(item) => (
<SettingsRowV2 title={item.name} description=""> <SettingsRowV2 title={item.name} description="">
<div> <div>
<Switch <SwitchV2
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })} checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)} onChange={(checked) => setModelVisibility(item, checked)}
hideLabel hideLabel
> >
{item.name} {item.name}
</Switch> </SwitchV2>
</div> </div>
</SettingsRowV2> </SettingsRowV2>
)} )}
@@ -263,6 +260,6 @@ export const DialogManageModelsV2: Component = () => {
</div> </div>
</div> </div>
</DialogBody> </DialogBody>
</Dialog> </DialogV2>
) )
} }
@@ -86,12 +86,12 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
<Show <Show
when={isLast()} when={isLast()}
fallback={ fallback={
<Button variant="neutral" size="large" onClick={handleNext}> <Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")} {language.t("dialog.releaseNotes.action.next")}
</Button> </Button>
} }
> >
<Button variant="contrast" size="large" onClick={handleClose}> <Button variant="primary" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")} {language.t("dialog.releaseNotes.action.getStarted")}
</Button> </Button>
</Show> </Show>
@@ -1,8 +1,8 @@
import "@pierre/trees/web-components" import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees" import { FileTree } from "@pierre/trees"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog" import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { Button } from "@opencode-ai/ui/button" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInput } from "@opencode-ai/ui/text-input" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -28,8 +28,8 @@ import {
pickerRoot, pickerRoot,
} from "./directory-picker-domain" } from "./directory-picker-domain"
import "./dialog-select-directory-v2.css" import "./dialog-select-directory-v2.css"
import { Divider } from "@opencode-ai/ui/divider" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/core/util/path"
interface DialogSelectDirectoryV2Props { interface DialogSelectDirectoryV2Props {
title?: string title?: string
@@ -293,10 +293,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
<DialogHeader> <DialogHeader>
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle> <DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
</DialogHeader> </DialogHeader>
<Divider /> <DividerV2 />
<DialogBody class="directory-picker-v2-body pt-4!"> <DialogBody class="directory-picker-v2-body pt-4!">
<div class="directory-picker-v2-path" ref={pathArea}> <div class="directory-picker-v2-path" ref={pathArea}>
<TextInput <TextInputV2
value={input()} value={input()}
autofocus autofocus
autocomplete="off" autocomplete="off"
@@ -318,15 +318,15 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onKeyDown={handleInputKey} onKeyDown={handleInputKey}
/> />
<div class="directory-picker-v2-actions"> <div class="directory-picker-v2-actions">
<Button size="small" variant="ghost" onClick={() => void navigate(home())}> <ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
~ ~
</Button> </ButtonV2>
<Button size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}> <ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
{language.t("dialog.directory.root")} {language.t("dialog.directory.root")}
</Button> </ButtonV2>
<Button size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}> <ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
{language.t("dialog.directory.parent")} {language.t("dialog.directory.parent")}
</Button> </ButtonV2>
</div> </div>
<Show when={suggestionsOpen() && currentSuggestions().length > 0}> <Show when={suggestionsOpen() && currentSuggestions().length > 0}>
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions"> <div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
@@ -379,12 +379,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div> <div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<Button variant="neutral" onClick={() => dialog.close()}> <ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </ButtonV2>
<Button variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}> <ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
{action[policy.action]} {action[policy.action]}
</Button> </ButtonV2>
</DialogFooter> </DialogFooter>
</Dialog> </Dialog>
) )
@@ -0,0 +1,187 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const platform = usePlatform()
const filesOnly = () => props.mode === "files"
if (!filesOnly()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
if (filesOnly() && platform.platform === "desktop") {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK.server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [grouped, setGrouped] = createSignal(false)
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (loaded || next.length > 0) {
void pending
return next
}
await pending
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
}
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
}
return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List
class="px-3"
search={{
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
>
{(item) => (
<Switch
fallback={
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(item.path ?? "")}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
</div>
</div>
</div>
}
>
<Match when={item.type === "command"}>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
</Show>
</div>
</Match>
<Match when={item.type === "session"}>
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
<div class="flex items-center gap-2 min-w-0">
<span
class="text-14-regular text-text-strong truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.title}
</span>
<Show when={item.description}>
<span
class="text-14-regular text-text-weak truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.description}
</span>
</Show>
</div>
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
</span>
</Show>
</div>
</Match>
</Switch>
)}
</List>
</Dialog>
)
}
@@ -1,7 +1,6 @@
import { Component, createMemo, Show } from "solid-js" import { Component, createMemo, Show } from "solid-js"
import { useData } from "@/context/server" import { useSync } from "@/context/sync"
import { useWorkspaceLocation } from "@/context/location" import { Dialog } from "@opencode-ai/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -15,13 +14,12 @@ const statusLabels = {
} as const } as const
export const DialogSelectMcp: Component = () => { export const DialogSelectMcp: Component = () => {
const data = useData() const sync = useSync()
const sdk = useWorkspaceLocation()
const language = useLanguage() const language = useLanguage()
const items = createMemo(() => const items = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []) Object.entries(sync().data.mcp ?? {})
.map((server) => ({ name: server.name, status: server.status.status })) .map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
@@ -31,71 +29,63 @@ export const DialogSelectMcp: Component = () => {
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
return ( return (
<Dialog> <Dialog
<DialogHeader> title={language.t("dialog.mcp.title")}
<DialogTitleGroup description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
title={language.t("dialog.mcp.title")} >
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })} <List
/> class="px-3"
</DialogHeader> search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
<DialogBody> emptyMessage={language.t("dialog.mcp.empty")}
<List key={(x) => x?.name ?? ""}
class="px-3" items={items}
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }} filterKeys={["name", "status"]}
emptyMessage={language.t("dialog.mcp.empty")} sortBy={(a, b) => a.name.localeCompare(b.name)}
key={(x) => x?.name ?? ""} onSelect={(x) => {
items={items} if (!x || x.status === "pending" || toggle.isPending) return
filterKeys={["name", "status"]} toggle.mutate(x.name)
sortBy={(a, b) => a.name.localeCompare(b.name)} }}
onSelect={(x) => { >
if (!x || x.status === "pending" || toggle.isPending) return {(i) => {
toggle.mutate(x.name) const mcpStatus = () => sync().data.mcp[i.name]
}} const status = () => mcpStatus()?.status
> const statusLabel = () => {
{(i) => { const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
const mcpStatus = () => if (!key) return
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name) return language.t(key)
?.status }
const status = () => mcpStatus()?.status const error = () => {
const statusLabel = () => { const s = mcpStatus()
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined if (s?.status === "failed") return s.error
if (!key) return }
return language.t(key) const enabled = () => status() === "connected"
} return (
const error = () => { <div class="w-full flex items-center justify-between gap-x-3">
const s = mcpStatus() <div class="flex flex-col gap-0.5 min-w-0">
if (s?.status === "failed") return s.error <div class="flex items-center gap-2">
} <span class="truncate">{i.name}</span>
const enabled = () => status() === "connected" <Show when={statusLabel()}>
return ( <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show> </Show>
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <Show when={error()}>
<Switch <span class="text-11-regular text-text-weaker truncate">{error()}</span>
appearance="standard" </Show>
checked={enabled()}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div>
</div> </div>
) <div onClick={(e) => e.stopPropagation()}>
}} <Switch
</List> checked={enabled()}
</DialogBody> disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div>
</div>
)
}}
</List>
</Dialog> </Dialog>
) )
} }
@@ -39,7 +39,7 @@ function SelectModelWithoutProviders() {
onMount(open) onMount(open)
return ( return (
<Button variant="neutral" onClick={open}> <Button variant="secondary" onClick={open}>
Open select model dialog Open select model dialog
</Button> </Button>
) )
@@ -1,8 +1,8 @@
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Badge } from "@opencode-ai/ui/badge" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
@@ -66,7 +66,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
}) })
return ( return (
<Dialog <DialogV2
fit fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]" containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]" class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
@@ -84,7 +84,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div> </div>
<For each={freeModels()}> <For each={freeModels()}>
{(item) => ( {(item) => (
<Tooltip <TooltipV2
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={6} gutter={6}
@@ -105,15 +105,15 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
onClick={() => selectModel(item)} onClick={() => selectModel(item)}
> >
<span class="min-w-0 truncate">{displayModelName(item.name)}</span> <span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge> <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Show when={item.latest}> <Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge> <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show> </Show>
<Show when={currentKey() === modelKey(item)}> <Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" /> <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show> </Show>
</button> </button>
</Tooltip> </TooltipV2>
)} )}
</For> </For>
</div> </div>
@@ -172,6 +172,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div> </div>
</div> </div>
</DialogBody> </DialogBody>
</Dialog> </DialogV2>
) )
} }
@@ -1,9 +1,9 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List, type ListRef } from "@opencode-ai/ui/list" import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Badge } from "@opencode-ai/ui/badge" import { Tag } from "@opencode-ai/ui/tag"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -40,113 +40,108 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
} }
return ( return (
<Dialog class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"> <Dialog
<DialogHeader> title={language.t("dialog.model.select.title")}
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle> class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
</DialogHeader> >
<DialogBody> <div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div> <List
<List class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible" ref={(ref) => (listRef = ref)}
ref={(ref) => (listRef = ref)} items={model.list}
items={model.list} current={model.current()}
current={model.current()} key={(x) => `${x.provider.id}:${x.id}`}
key={(x) => `${x.provider.id}:${x.id}`} itemWrapper={(item, node) => (
itemWrapper={(item, node) => ( <Tooltip
<Tooltip class="w-full"
appearance="standard" placement="right-start"
class="w-full" gutter={12}
placement="right-start" value={
gutter={12} <ModelTooltip
value={ model={item}
<ModelTooltip latest={item.latest}
model={item} free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
latest={item.latest} />
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)} }
/> >
} {node}
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>{language.t("model.tag.free")}</Tag>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
</Show>
</div>
)}
</List>
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full">
<List
class="w-full px-3"
key={(p) => p.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
connect(x.id)
}}
> >
{node} {(i) => (
</Tooltip> <div class="w-full flex items-center gap-x-3">
)} <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
onSelect={(x) => { <span>{i.name}</span>
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { <Show when={i.id === "opencode"}>
recent: true, <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
}) </Show>
dialog.close() <Show when={i.id === "opencode"}>
}} <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
> </Show>
{(i) => ( <Show when={i.id === "opencode-go"}>
<div class="w-full flex items-center gap-x-2.5"> <>
<span>{i.name}</span>
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
<Show when={i.latest}>
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
)}
</List>
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full">
<List
class="w-full px-3"
key={(p) => p.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
connect(x.id)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak"> <div class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencode.tagline")} {language.t("dialog.provider.opencodeGo.tagline")}
</div> </div>
</Show> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
<Show when={i.id === "opencode"}> </>
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge> </Show>
</Show> <Show when={i.id === "anthropic"}>
<Show when={i.id === "opencode-go"}> <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
<> </Show>
<div class="text-14-regular text-text-weak"> </div>
{language.t("dialog.provider.opencodeGo.tagline")} )}
</div> </List>
<Badge appearance="standard">{language.t("dialog.provider.tag.recommended")}</Badge> <Button
</> variant="ghost"
</Show> class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
<Show when={i.id === "anthropic"}> icon="dot-grid"
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div> onClick={all}
</Show> >
</div> {language.t("dialog.provider.viewAll")}
)} </Button>
</List>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={all}
>
{language.t("dialog.provider.viewAll")}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
</DialogBody> </div>
</Dialog> </Dialog>
) )
} }
@@ -5,14 +5,16 @@ import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Badge } from "@opencode-ai/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Menu } from "@opencode-ai/ui/menu" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
@@ -79,7 +81,6 @@ const ModelList: Component<{
}} }}
itemWrapper={(item, node) => ( itemWrapper={(item, node) => (
<Tooltip <Tooltip
appearance="standard"
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
@@ -100,10 +101,10 @@ const ModelList: Component<{
<div class="w-full flex items-center gap-x-2 text-13-regular"> <div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span> <span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}> <Show when={isFree(i.provider.id, i.cost)}>
<Badge appearance="standard">{language.t("model.tag.free")}</Badge> <Tag>{language.t("model.tag.free")}</Tag>
</Show> </Show>
<Show when={i.latest}> <Show when={i.latest}>
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge> <Tag>{language.t("model.tag.latest")}</Tag>
</Show> </Show>
</div> </div>
)} )}
@@ -192,19 +193,21 @@ export function ModelSelectorPopover(props: {
class="p-1" class="p-1"
action={ action={
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Tooltip appearance="standard" placement="top" value={language.t("command.provider.connect")}> <Tooltip placement="top" value={language.t("command.provider.connect")}>
<IconButton <IconButton
icon={<Icon name="plus-small" />} icon="plus-small"
variant="ghost" variant="ghost"
iconSize="normal"
class="size-6" class="size-6"
aria-label={language.t("command.provider.connect")} aria-label={language.t("command.provider.connect")}
onClick={handleConnectProvider} onClick={handleConnectProvider}
/> />
</Tooltip> </Tooltip>
<Tooltip appearance="standard" placement="top" value={language.t("dialog.model.manage")}> <Tooltip placement="top" value={language.t("dialog.model.manage")}>
<IconButton <IconButton
icon={<Icon name="sliders" />} icon="sliders"
variant="ghost" variant="ghost"
iconSize="normal"
class="size-6" class="size-6"
aria-label={language.t("dialog.model.manage")} aria-label={language.t("dialog.model.manage")}
onClick={handleManage} onClick={handleManage}
@@ -370,10 +373,10 @@ function ModelSelectorPopoverV2View(props: {
}) })
return ( return (
<Menu open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}> <MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<Menu.Trigger as={props.trigger} /> <MenuV2.Trigger as={props.trigger} />
<Menu.Portal> <MenuV2.Portal>
<Menu.Content <MenuV2.Content
ref={(element: HTMLDivElement) => (contentRef = element)} ref={(element: HTMLDivElement) => (contentRef = element)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={dismiss.preventTriggerRestore} onPointerDownOutside={dismiss.preventTriggerRestore}
@@ -446,14 +449,14 @@ function ModelSelectorPopoverV2View(props: {
> >
<For each={groups()}> <For each={groups()}>
{(group) => ( {(group) => (
<Menu.Group> <MenuV2.Group>
<Menu.GroupLabel class="gap-2 px-3"> <MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span> <span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</Menu.GroupLabel> </MenuV2.GroupLabel>
<Menu.RadioGroup value={props.current}> <MenuV2.RadioGroup value={props.current}>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(item) => (
<Tooltip <TooltipV2
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={6} gutter={6}
@@ -467,7 +470,7 @@ function ModelSelectorPopoverV2View(props: {
/> />
} }
> >
<Menu.RadioItem <MenuV2.RadioItem
value={modelKey(item)} value={modelKey(item)}
data-option-key={modelKey(item)} data-option-key={modelKey(item)}
data-selected-model={props.current === modelKey(item) ? true : undefined} data-selected-model={props.current === modelKey(item) ? true : undefined}
@@ -481,17 +484,17 @@ function ModelSelectorPopoverV2View(props: {
> >
<span class="min-w-0 truncate leading-5">{item.name}</span> <span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}> <Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge> <TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show> </Show>
<Show when={item.latest}> <Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge> <TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show> </Show>
</Menu.RadioItem> </MenuV2.RadioItem>
</Tooltip> </TooltipV2>
)} )}
</For> </For>
</Menu.RadioGroup> </MenuV2.RadioGroup>
</Menu.Group> </MenuV2.Group>
)} )}
</For> </For>
</Show> </Show>
@@ -499,7 +502,7 @@ function ModelSelectorPopoverV2View(props: {
</ScrollView> </ScrollView>
<div class="h-px bg-v2-border-border-muted" /> <div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<Menu.Item <MenuV2.Item
data-option-key={manageKey} data-option-key={manageKey}
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }} classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
onMouseEnter={() => { onMouseEnter={() => {
@@ -510,11 +513,11 @@ function ModelSelectorPopoverV2View(props: {
> >
<Icon name="outline-sliders" size="small" /> <Icon name="outline-sliders" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span> <span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
</Menu.Item> </MenuV2.Item>
</div> </div>
</Menu.Content> </MenuV2.Content>
</Menu.Portal> </MenuV2.Portal>
</Menu> </MenuV2>
) )
} }
@@ -537,19 +540,18 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
} }
return ( return (
<Dialog> <Dialog
<DialogHeader hideClose> title={language.t("dialog.model.select.title")}
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle> action={
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}> <Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
{language.t("command.provider.connect")} {language.t("command.provider.connect")}
</Button> </Button>
</DialogHeader> }
<DialogBody> >
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} /> <ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}> <Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
{language.t("dialog.model.manage")} {language.t("dialog.model.manage")}
</Button> </Button>
</DialogBody>
</Dialog> </Dialog>
) )
} }
@@ -1,6 +1,5 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Menu } from "@opencode-ai/ui/menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
@@ -83,7 +82,7 @@ function ServerForm(props: ServerFormProps) {
type="text" type="text"
label={language.t("dialog.server.add.name")} label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
defaultValue={props.name} value={props.name}
disabled={props.busy} disabled={props.busy}
onChange={props.onNameChange} onChange={props.onNameChange}
onKeyDown={keyDown} onKeyDown={keyDown}
@@ -93,7 +92,7 @@ function ServerForm(props: ServerFormProps) {
type="text" type="text"
label={language.t("dialog.server.add.username")} label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
defaultValue={props.username} value={props.username}
disabled={props.busy} disabled={props.busy}
onChange={props.onUsernameChange} onChange={props.onUsernameChange}
onKeyDown={keyDown} onKeyDown={keyDown}
@@ -102,7 +101,7 @@ function ServerForm(props: ServerFormProps) {
type="password" type="password"
label={language.t("dialog.server.add.password")} label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
defaultValue={props.password} value={props.password}
disabled={props.busy} disabled={props.busy}
onChange={props.onPasswordChange} onChange={props.onPasswordChange}
onKeyDown={keyDown} onKeyDown={keyDown}
@@ -157,47 +156,49 @@ export function ServerConnectionList(props: {
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={i.type === "http"}> <Show when={i.type === "http"}>
<Menu appearance="standard"> <DropdownMenu>
<Menu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
icon={<Icon name="dot-grid" />} icon="dot-grid"
variant="ghost" variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()} onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()} onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/> />
<Menu.Portal> <DropdownMenu.Portal>
<Menu.Content class="mt-1"> <DropdownMenu.Content class="mt-1">
<Menu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.onEdit(i) props.onEdit(i)
}} }}
> >
{language.t("dialog.server.menu.edit")} <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</Menu.Item> </DropdownMenu.Item>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<Menu.Item onSelect={() => props.domain.defaults.set(key)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")} <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</Menu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Menu.Item onSelect={() => props.domain.defaults.set(null)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")} <DropdownMenu.ItemLabel>
</Menu.Item> {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show> </Show>
<Show when={props.domain.connection.canRemove(key)}> <Show when={props.domain.connection.canRemove(key)}>
<Menu.Separator /> <DropdownMenu.Separator />
<Menu.Item <DropdownMenu.Item
onSelect={() => props.domain.connection.remove(key)} onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
{language.t("dialog.server.menu.delete")} <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</Menu.Item> </DropdownMenu.Item>
</Show> </Show>
</Menu.Content> </DropdownMenu.Content>
</Menu.Portal> </DropdownMenu.Portal>
</Menu> </DropdownMenu>
</Show> </Show>
</div> </div>
</div> </div>
@@ -207,7 +208,7 @@ export function ServerConnectionList(props: {
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="neutral" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.onAdd} onClick={props.onAdd}
@@ -243,7 +244,7 @@ export function ServerConnectionForm(props: { form: ServerConnectionFormControll
/> />
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="contrast" variant="primary"
size="large" size="large"
onClick={props.form.submit} onClick={props.form.submit}
disabled={props.form.state.busy()} disabled={props.form.state.busy()}
@@ -2,7 +2,7 @@ import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { JSX } from "solid-js" import { JSX } from "solid-js"
export type DialogGoUpsellProps = { export type DialogGoUpsellProps = {
@@ -30,22 +30,17 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
} }
return ( return (
<Dialog fit> <Dialog title={props.title} description={props.description} fit>
<DialogHeader> <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<DialogTitleGroup title={props.title} description={props.description} /> <div class="flex justify-end gap-2">
</DialogHeader> <Button variant="ghost" size="large" onClick={dismiss}>
<DialogBody> {language.t("dialog.usageExceeded.dontShowAgain")}
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> </Button>
<div class="flex justify-end gap-2"> <Button variant="primary" size="large" onClick={runAction}>
<Button variant="ghost" size="large" onClick={dismiss}> {props.actionLabel}
{language.t("dialog.usageExceeded.dontShowAgain")} </Button>
</Button>
<Button variant="contrast" size="large" onClick={runAction}>
{props.actionLabel}
</Button>
</div>
</div> </div>
</DialogBody> </div>
</Dialog> </Dialog>
) )
} }
@@ -245,7 +245,7 @@ export function nativePickerPath(path: string) {
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\") if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
return value return value
} }
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk" import { ServerSDK } from "@/context/server-sdk"
+1 -1
View File
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils" import { normalizeProjectInfo } from "@/context/global-sync/utils"
+2 -2
View File
@@ -1,6 +1,6 @@
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/file-tree.css" import "@opencode-ai/ui/v2/file-tree-v2.css"
import { import {
createEffect, createEffect,
createMemo, createMemo,
@@ -13,7 +13,7 @@ import {
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@/types" import type { FileNode } from "@/types"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { import {
@@ -1,13 +1,13 @@
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { Tabs } from "@opencode-ai/ui/tabs" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js" import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server" import { useServerSync } from "@/context/server-sync"
import { pluginLabel } from "@/utils/plugin" import { useSync } from "@/context/sync"
import { ExternalLink } from "./external-link" import { ExternalLink } from "./external-link"
type SkillItem = { type SkillItem = {
@@ -15,6 +15,7 @@ type SkillItem = {
location: string location: string
} }
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}` const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: unknown }> = (props) => ( const ExtensionCard: Component<{ children: unknown }> = (props) => (
@@ -22,8 +23,9 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => (
) )
const ExtensionRow: Component<{ const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill" icon: "mcp" | "cube" | "post-skill" | "code"
name: string name: string
status?: string
children?: unknown children?: unknown
}> = (props) => ( }> = (props) => (
<div class="project-settings-extension-row"> <div class="project-settings-extension-row">
@@ -31,6 +33,14 @@ const ExtensionRow: Component<{
<Icon name={props.icon} class="project-settings-extension-row-icon" /> <Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span> <span class="project-settings-extension-row-name">{props.name}</span>
</div> </div>
<Show when={props.status}>
{(status) => (
<span class="project-settings-extension-row-status">
<span class="project-settings-extension-row-status-dot" />
{status()}
</span>
)}
</Show>
{props.children as any} {props.children as any}
</div> </div>
) )
@@ -65,55 +75,56 @@ const SharedSection: Component<{
export const ProjectSettingsExtensions: Component = () => { export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const directorySDK = useWorkspaceLocation() const directorySDK = useSDK()
const data = useData() const serverSync = useServerSync()
const toggleMcp = useMcpToggle(() => directorySDK().directory) const sync = useSync()
const toggleMcp = useMcpToggle()
createEffect(() => {
if (serverSDK.connection.status() !== "connected") return
const ref = { directory: directorySDK().directory }
void Promise.all([
data.location.mcp.server.sync(),
data.location.skill.sync(),
data.location.mcp.server.sync(ref),
data.location.skill.sync(ref),
]).catch(() => undefined)
})
const [serverMcp] = createResource(
serverSDK,
(sdk) =>
sdk.api.mcp
.list()
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
.catch(() => ({})),
{ initialValue: {} },
)
const globalMcpNames = createMemo(() => const globalMcpNames = createMemo(() =>
[...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(), [...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
) )
const projectMcpNames = createMemo(() => { const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames()) const shared = new Set(globalMcpNames())
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? []) const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
.map((server) => server.name) if (configured.length > 0) return configured.sort()
return Object.keys(sync().data.mcp ?? {})
.filter((name) => !shared.has(name)) .filter((name) => !shared.has(name))
.sort() .sort()
}) })
const mcpEnabled = (name: string) => const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
?.status.status === "connected"
const [globalPluginList] = createResource( const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
() => serverSDK.connection.status() === "connected",
() => serverSDK.api.plugin.list().then((result) => result.data),
)
const [projectPluginList] = createResource(
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => { const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins()) const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name)) return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
}) })
const serverSkills = createMemo(() => data.location.skill.list() ?? []) const [serverSkills] = createResource(
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const projectSkills = createMemo(() => { const projectSkills = createMemo(() => {
const shared = new Set(serverSkills().map(skillKey)) const shared = new Set(serverSkills.latest.map(skillKey))
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter( return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
(item) => !shared.has(skillKey(item)),
)
}) })
const mcpRows = (items: string[]) => ( const mcpRows = (items: string[]) => (
@@ -149,15 +160,15 @@ export const ProjectSettingsExtensions: Component = () => {
<span>{language.t("project.settings.extensions.description")}</span> <span>{language.t("project.settings.extensions.description")}</span>
</div> </div>
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs"> <TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<Tabs.List> <TabsV2.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger> <TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger> <TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger> <TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */} <TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
</Tabs.List> </TabsV2.List>
<Tabs.Content value="mcps"> <TabsV2.Content value="mcps">
<div class="project-settings-extension-section"> <div class="project-settings-extension-section">
<div class="project-settings-extension-section-header"> <div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span> <span>{language.t("project.settings.extensions.added")}</span>
@@ -168,9 +179,9 @@ export const ProjectSettingsExtensions: Component = () => {
</Show> </Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection> <SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div> </div>
</Tabs.Content> </TabsV2.Content>
<Tabs.Content value="plugins"> <TabsV2.Content value="plugins">
<div class="project-settings-extension-section"> <div class="project-settings-extension-section">
<div class="project-settings-extension-section-header"> <div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span> <span>{language.t("project.settings.extensions.added")}</span>
@@ -181,9 +192,9 @@ export const ProjectSettingsExtensions: Component = () => {
</Show> </Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection> <SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div> </div>
</Tabs.Content> </TabsV2.Content>
<Tabs.Content value="skills"> <TabsV2.Content value="skills">
<div class="project-settings-extension-section"> <div class="project-settings-extension-section">
<div class="project-settings-extension-section-header"> <div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span> <span>{language.t("project.settings.extensions.added")}</span>
@@ -194,10 +205,34 @@ export const ProjectSettingsExtensions: Component = () => {
<Show when={projectSkills().length > 0}> <Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard> <ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show> </Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection> <SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
</div> </div>
</Tabs.Content> </TabsV2.Content>
</Tabs>
<TabsV2.Content value="lsps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show when={sync().data.lsp.length > 0}>
<ExtensionCard>
<For each={sync().data.lsp}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name || item.id}
status={
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
}
/>
)}
</For>
</ExtensionCard>
</Show>
</div>
</TabsV2.Content>
</TabsV2>
</div> </div>
) )
} }
+26 -23
View File
@@ -1,10 +1,10 @@
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Button } from "@opencode-ai/ui/button" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Keybind } from "@opencode-ai/ui/keybind" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/client/promise" import type { ReferenceInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo, on, Show } from "solid-js" import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
@@ -22,8 +22,8 @@ import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useWorkspaceLocation } from "@/context/location" import { useSDK } from "@/context/sdk"
import { useData } from "@/context/server" import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
@@ -81,8 +81,8 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
} }
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useWorkspaceLocation() const sdk = useSDK()
const data = useData() const sync = useSync()
const files = useFile() const files = useFile()
const layout = useLayout() const layout = useLayout()
const comments = useComments() const comments = useComments()
@@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
return [...result, path] return [...result, path]
}, []) }, [])
}) })
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined)) const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running") const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const attachments = createMemo(() => const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -228,8 +228,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
const referenceDescription = (reference: ReferenceInfo) => const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() => const references = createMemo(() =>
(data.location.reference.list({ directory: sdk().directory }) ?? []) sync()
.filter((reference) => !reference.hidden) .data.reference.filter((reference) => !reference.hidden)
.map((reference) => ({ .map((reference) => ({
id: `reference:${reference.name}`, id: `reference:${reference.name}`,
kind: "reference" as const, kind: "reference" as const,
@@ -248,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
) )
const resources = createMemo(() => const resources = createMemo(() =>
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({ Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.server}:${resource.uri}`, id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const, kind: "resource" as const,
label: `@${resource.name}`, label: `@${resource.name}`,
@@ -294,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
]) ])
const slashCommands = createMemo(() => [ const slashCommands = createMemo(() => [
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({ ...sync().data.command.map((item) => ({
id: `custom.${item.name}`, id: `custom.${item.name}`,
trigger: item.name, trigger: item.name,
title: item.name, title: item.name,
@@ -354,7 +354,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />), dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
openContext(key) { openContext(key) {
const item = controller.contextItem(key) const item = controller.contextItem(key)
if (item) openComment(item, props, layout, files, comments) if (item) openComment(item, props, sync, layout, files, comments)
}, },
onEditor(element) { onEditor(element) {
editor = element as HTMLDivElement editor = element as HTMLDivElement
@@ -503,20 +503,20 @@ function PromptInputV2ModelControl(props: {
) )
return ( return (
<Show when={!props.loading}> <Show when={!props.loading}>
<Tooltip <TooltipV2
placement="top" placement="top"
gutter={4} gutter={4}
value={ value={
<> <>
{props.title} {props.title}
<Keybind keys={props.keybind} variant="neutral" /> <KeybindV2 keys={props.keybind} variant="neutral" />
</> </>
} }
> >
<Show <Show
when={props.paid} when={props.paid}
fallback={ fallback={
<Button <ButtonV2
data-action="prompt-model" data-action="prompt-model"
data-control-type="dialog" data-control-type="dialog"
variant="ghost-muted" variant="ghost-muted"
@@ -527,13 +527,13 @@ function PromptInputV2ModelControl(props: {
onClick={props.onUnpaidClick} onClick={props.onUnpaidClick}
> >
{content()} {content()}
</Button> </ButtonV2>
} }
> >
<ModelSelectorPopoverV2 <ModelSelectorPopoverV2
model={props.model} model={props.model}
trigger={(triggerProps) => ( trigger={(triggerProps) => (
<Button <ButtonV2
{...triggerProps} {...triggerProps}
variant="ghost-muted" variant="ghost-muted"
size="normal" size="normal"
@@ -544,12 +544,12 @@ function PromptInputV2ModelControl(props: {
data-control-type="popover" data-control-type="popover"
> >
{content()} {content()}
</Button> </ButtonV2>
)} )}
onClose={props.onClose} onClose={props.onClose}
/> />
</Show> </Show>
</Tooltip> </TooltipV2>
</Show> </Show>
) )
} }
@@ -557,6 +557,7 @@ function PromptInputV2ModelControl(props: {
function openComment( function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps, props: PromptInputV2ControllerProps,
sync: ReturnType<typeof useSync>,
layout: ReturnType<typeof useLayout>, layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>, files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>, comments: ReturnType<typeof useComments>,
@@ -574,7 +575,9 @@ function openComment(
}) })
}) })
} }
const review = item.commentOrigin === "review" const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
const review =
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (review) { if (review) {
layout.fileTree.setTab("changes") layout.fileTree.setTab("changes")
@@ -1,6 +1,8 @@
// @ts-nocheck // @ts-nocheck
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import { createPromptState } from "@/context/prompt" import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input" import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() { function createPromptInputStoryRuntime() {
@@ -109,6 +111,93 @@ function PromptInputExample() {
) )
} }
const todos: Todo[] = [
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
]
function PromptInputWithOpenDock() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
activeTab: undefined as string | undefined,
todoCollapsed: false,
})
const inputControls = {
agents: {
available: [],
options: ["build"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => [],
open: () => {},
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: { opened: () => false, open: () => {} },
},
}
const state = {
blocked: () => false,
questionRequest: () => undefined,
permissionRequest: () => undefined,
permissionResponding: () => false,
decide: () => {},
todos: () => todos,
dock: () => true,
closing: () => false,
opening: () => false,
}
return (
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt: input.state,
ready: () => true,
centered: () => false,
todo: {
collapsed: () => controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
},
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
)
}
export default { export default {
title: "App/PromptInput", title: "App/PromptInput",
id: "app-prompt-input", id: "app-prompt-input",
@@ -123,3 +212,12 @@ export const Basic = {
</div> </div>
), ),
} }
export const DockAlreadyOpen = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
<PromptInputWithOpenDock />
</div>
),
}
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
@@ -11,6 +11,18 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
} }
const admitted: Array<{
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = [] const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
@@ -24,10 +36,13 @@ const switchedModels: Array<{
}> = [] }> = []
const sessionRequestOrder: string[] = [] const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const admittedServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
const navigations: string[] = [] const navigations: string[] = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let restoredPrompts = 0 let restoredPrompts = 0
let clearEchoCalls = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
@@ -38,9 +53,12 @@ let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined let worktreeFailure: Error | undefined
let locationFailure: Error | undefined let locationFailure: Error | undefined
let promptFailure: Error | undefined let promptFailure: Error | undefined
let clearEchoResult = true
let worktreeCreates = 0 let worktreeCreates = 0
let activeSDK = "server-a" let activeSDK = "server-a"
let activeServer = "server-a" let activeServer = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let commands: Array<{ name: string }> = [] let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0" let worktreeDirectory = "/repo/new-0"
let worktreeID = 0 let worktreeID = 0
@@ -173,7 +191,7 @@ beforeAll(async () => {
showToast: () => 0, showToast: () => 0,
})) }))
mock.module("@opencode-ai/util/encode", () => ({ mock.module("@opencode-ai/core/util/encode", () => ({
base64Decode: (value: string) => value, base64Decode: (value: string) => value,
base64Encode: (value: string) => value, base64Encode: (value: string) => value,
checksum: (value: string) => value, checksum: (value: string) => value,
@@ -199,6 +217,10 @@ beforeAll(async () => {
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) } return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
}) })
mock.module("@/context/server", () => ({
useServer: () => ({ key: activeServer }),
}))
mock.module("@/context/tabs", () => ({ mock.module("@/context/tabs", () => ({
useTabs: () => ({ useTabs: () => ({
updateDraft: (draftID: string, draft: { worktree?: string }) => { updateDraft: (draftID: string, draft: { worktree?: string }) => {
@@ -214,35 +236,83 @@ beforeAll(async () => {
usePrompt: () => prompt, usePrompt: () => prompt,
})) }))
mock.module("@/context/location", () => ({ mock.module("@/context/sdk", () => ({
useWorkspaceLocation: () => { useSDK: () => {
return () => ({ return () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other", directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
api: rootClient.api,
url: "http://localhost:4096",
}) })
}, },
})) }))
mock.module("@/context/server-sdk", () => ({ mock.module("@/context/sync", () => ({
useServerSDK: () => ({ useSync: () => () => {
scope: activeSDK === "server-a" ? ServerScope.local : "server-b", const server = activeDirectorySync
api: rootClient.api, return {
}), data: { command: commands, project: "project" },
session: {
inbox: {
echo: (value: {
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}) => {
admittedServers.push(server)
admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
},
},
},
set: () => undefined,
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}
},
})) }))
mock.module("@/context/server", () => ({ mock.module("@/context/server-sync", () => ({
useServer: () => ({ key: activeServer }), useServerSync: () => {
useData: () => ({ const server = activeServerSync
session: { return {
remember: () => undefined, session: {
setStatus: () => undefined, remember: () => undefined,
}, set: () => undefined,
location: { sync: async () => {
info: () => ({ project: { id: "project", directory: "/repo/main" } }), serverSessionSyncs++
command: { },
list: () => commands,
}, },
}, child: (directory: string) => {
}), syncedServers.push(server)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
},
}
},
})) }))
mock.module("@/context/platform", () => ({ mock.module("@/context/platform", () => ({
@@ -263,6 +333,8 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdSessions.length = 0 createdSessions.length = 0
admitted.length = 0
confirmed.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0 updatedDrafts.length = 0
sentCommands.length = 0 sentCommands.length = 0
@@ -271,9 +343,12 @@ beforeEach(() => {
switchedAgents.length = 0 switchedAgents.length = 0
switchedModels.length = 0 switchedModels.length = 0
sessionRequestOrder.length = 0 sessionRequestOrder.length = 0
syncedServers.length = 0
admittedServers.length = 0
promptCaptures.length = 0 promptCaptures.length = 0
navigations.length = 0 navigations.length = 0
restoredPrompts = 0 restoredPrompts = 0
clearEchoCalls = 0
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
@@ -282,6 +357,8 @@ beforeEach(() => {
variant = undefined variant = undefined
activeSDK = "server-a" activeSDK = "server-a"
activeServer = "server-a" activeServer = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = [] commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}` worktreeDirectory = `/repo/new-${++worktreeID}`
@@ -291,8 +368,10 @@ beforeEach(() => {
worktreeFailure = undefined worktreeFailure = undefined
locationFailure = undefined locationFailure = undefined
promptFailure = undefined promptFailure = undefined
clearEchoResult = true
worktreeCreates = 0 worktreeCreates = 0
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
const event = { preventDefault: () => undefined } as unknown as Event const event = { preventDefault: () => undefined } as unknown as Event
@@ -367,6 +446,8 @@ describe("prompt submit worktree selection", () => {
const result = submit.handleSubmit(event) const result = submit.handleSubmit(event)
activeSDK = "server-b" activeSDK = "server-b"
activeServer = "server-b" activeServer = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2" search.draftId = "draft-2"
release() release()
await result await result
@@ -374,6 +455,8 @@ describe("prompt submit worktree selection", () => {
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }]) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(admittedServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local }) expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local })
expect(submitted).toBe(0) expect(submitted).toBe(0)
}) })
@@ -393,6 +476,15 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
expect(admitted).toHaveLength(1)
expect(admitted[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
})
expect(admitted[0]?.messageID).toStartWith("msg_")
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
expect(sentPrompts).toEqual(["/repo/main"]) expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }]) expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([ expect(switchedModels).toEqual([
@@ -407,19 +499,14 @@ describe("prompt submit worktree selection", () => {
text: "ls", text: "ls",
files: [], files: [],
agents: [], agents: [],
metadata: {
displayText: "ls",
comments: [],
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
},
}) })
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
}) })
test("restores the prompt when sending fails", async () => { test("keeps a confirmed echo when the prompt response is lost", async () => {
params = { id: "session-1" } params = { id: "session-1" }
promptFailure = new Error("connection lost") promptFailure = new Error("connection lost")
clearEchoResult = false
const submit = makeSubmit({ const submit = makeSubmit({
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }), info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
}) })
@@ -427,7 +514,9 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
await settle() await settle()
expect(restoredPrompts).toBe(1) expect(admitted).toHaveLength(1)
expect(clearEchoCalls).toBe(1)
expect(restoredPrompts).toBe(0)
}) })
test("submits slash commands through the current session API", async () => { test("submits slash commands through the current session API", async () => {
@@ -1,18 +1,19 @@
import type { Data } from "@opencode-ai/client/solid" import type { SessionInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { startTransition, type Accessor } from "solid-js" import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useData } from "@/context/server" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location" import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/util/path" import { getDirectory } from "@opencode-ai/core/util/path"
import { buildPromptRequest } from "./build-prompt-request" import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
@@ -36,8 +37,9 @@ export type FollowupDraft = {
} }
type FollowupSendInput = { type FollowupSendInput = {
api: ServerSDK["api"]["session"] api: DirectorySDK["api"]["session"]
data: Data serverSync: ServerSync
sync: DirectorySync
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
@@ -53,20 +55,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const setBusy = () => { const setBusy = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.data.session.setStatus(input.draft.sessionID, "running") input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
} }
const setIdle = () => { const setIdle = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.data.session.setStatus(input.draft.sessionID, "idle") input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
} }
const [head, ...tail] = text.split(" ") const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if ( if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
cmd &&
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
) {
setBusy() setBusy()
try { try {
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
@@ -111,6 +110,14 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
setBusy() setBusy()
input.sync.session.inbox.echo({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
...request,
})
try { try {
const session = input.session() const session = input.session()
@@ -132,24 +139,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
} }
await input.api.prompt({ const admitted = await input.api.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID, id: messageID,
text: request.text, text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents, agents: request.agents,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: input.draft.agent,
model: {
...input.draft.model,
...(input.draft.variant ? { variant: input.draft.variant } : {}),
},
},
}) })
input.sync.session.inbox.confirm(admitted)
return true return true
} catch (err) { } catch (err) {
const failed = input.sync.session.inbox.clearEcho({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
if (!failed) return true
setIdle() setIdle()
throw err throw err
} }
@@ -183,9 +188,9 @@ type PromptSubmitInput = {
export function createPromptSubmit(input: PromptSubmitInput) { export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useWorkspaceLocation() const sdk = useSDK()
const serverSDK = useServerSDK() const sync = useSync()
const data = useData() const serverSync = useServerSync()
const server = useServer() const server = useServer()
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
@@ -207,9 +212,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => { const abort = async () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
serverSync.session.set("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
return serverSDK.api.session.interrupt({ sessionID }).catch(() => {}) return sdk()
.api.session.interrupt({ sessionID })
.catch(() => {})
} }
const restoreCommentItems = ( const restoreCommentItems = (
@@ -235,6 +244,21 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
target.session.remember(info)
const [, setStore] = target.child(dir)
setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
next[result.index] = info
return next
}
next.splice(result.index, 0, info)
return next
})
}
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
@@ -267,9 +291,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const submissionSDK = sdk() const submissionSDK = sdk()
const submissionServerSDK = serverSDK const submissionSync = sync()
const submissionData = data const submissionServerSync = serverSync
const submissionScope = submissionServerSDK.scope const submissionScope = submissionSDK.scope
const submissionServer = server.key const submissionServer = server.key
const projectDirectory = submissionSDK.directory const projectDirectory = submissionSDK.directory
const sessionID = params.id const sessionID = params.id
@@ -297,16 +321,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
if (isNewSession) { if (isNewSession) {
if (worktreeSelection === "create") { if (worktreeSelection === "create") {
const createdWorktree = await submissionServerSDK.api.worktree const createdWorktree = await submissionSDK.api.worktree
.create({ .create({
projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "", projectID: submissionSync.data.project,
strategy: "git", strategy: "git",
directory: getDirectory( directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
),
}) })
.then(async (created) => { .then(async (created) => {
await submissionServerSDK.api.location.get({ location: { directory: created.directory } }) await submissionSDK.api.location.get({ location: { directory: created.directory } })
return created return created
}) })
.catch((err) => { .catch((err) => {
@@ -323,11 +345,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (worktreeSelection !== "main" && worktreeSelection !== "create") { if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection sessionDirectory = worktreeSelection
} }
if (sessionDirectory !== projectDirectory) {
submissionServerSync.child(sessionDirectory)
}
} }
let session = currentSession let session = currentSession
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await submissionServerSDK.api.session const created = await submissionSDK.api.session
.create({ .create({
agent: currentAgent.name, agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
@@ -341,7 +367,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return undefined return undefined
}) })
if (created) { if (created) {
submissionData.session.remember(created) seed(submissionServerSync, sessionDirectory, created)
session = created session = created
await startTransition(() => { await startTransition(() => {
if (!session) return if (!session) return
@@ -422,7 +448,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") { if (mode === "shell") {
clearInput() clearInput()
const eventID = Event.ID.create() const eventID = Event.ID.create()
void submissionServerSDK.api.session void submissionSDK.api.session
.shell({ .shell({
sessionID: session.id, sessionID: session.id,
id: eventID, id: eventID,
@@ -441,14 +467,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (text.startsWith("/")) { if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ") const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1) const commandName = cmdName.slice(1)
const customCommand = submissionData.location.command const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
.list({ directory: sessionDirectory })
?.find((command) => command.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
submissionData.session.setStatus(session.id, "running") submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void submissionServerSDK.api.session void submissionSDK.api.session
.command({ .command({
sessionID: session.id, sessionID: session.id,
id: messageID, id: messageID,
@@ -464,7 +488,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
), ),
}) })
.catch((err) => { .catch((err) => {
submissionData.session.setStatus(session.id, "idle") submissionServerSync.session.set("session_status", session.id, { type: "idle" })
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -482,15 +506,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput() clearInput()
void sendFollowupDraft({ void sendFollowupDraft({
api: submissionServerSDK.api.session, api: submissionSDK.api.session,
data: submissionData, sync: submissionSync,
serverSync: submissionServerSync,
session: () => session, session: () => session,
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => { }).catch((err) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
submissionData.session.setStatus(session.id, "idle") submissionSync.set("session_status", session.id, { type: "idle" })
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@@ -9,9 +9,10 @@ import {
type ComponentProps, type ComponentProps,
} from "solid-js" } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Menu } from "@opencode-ai/ui/menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { getProjectAvatarVariant } from "@/context/layout" import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
@@ -276,8 +277,7 @@ export function PromptProjectSelector(props: {
}) })
return ( return (
<Menu <DropdownMenu
appearance="standard"
open={triggerReady() && props.controller.open()} open={triggerReady() && props.controller.open()}
placement={props.placement ?? "bottom"} placement={props.placement ?? "bottom"}
gutter={4} gutter={4}
@@ -287,9 +287,9 @@ export function PromptProjectSelector(props: {
props.controller.setOpen(open) props.controller.setOpen(open)
}} }}
> >
<Menu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} /> <DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<Menu.Portal> <DropdownMenu.Portal>
<Menu.Content <DropdownMenu.Content
ref={contentRef} ref={contentRef}
id="prompt-project-menu" id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none" class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
@@ -360,13 +360,13 @@ export function PromptProjectSelector(props: {
<Show <Show
when={props.controller.servers().length > 1} when={props.controller.servers().length > 1}
fallback={ fallback={
<Menu.RadioGroup value={selectedValue()}> <DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}> <For each={props.controller.projects()}>
{(project) => ( {(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} /> <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)} )}
</For> </For>
</Menu.RadioGroup> </DropdownMenu.RadioGroup>
} }
> >
<For <For
@@ -381,7 +381,7 @@ export function PromptProjectSelector(props: {
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint"> <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name} {server!.name}
</div> </div>
<Menu.RadioGroup value={selectedValue()}> <DropdownMenu.RadioGroup value={selectedValue()}>
<For <For
each={props.controller.projects().filter((project) => project.server?.key === server!.key)} each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
> >
@@ -389,7 +389,7 @@ export function PromptProjectSelector(props: {
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} /> <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)} )}
</For> </For>
</Menu.RadioGroup> </DropdownMenu.RadioGroup>
</div> </div>
)} )}
</For> </For>
@@ -408,8 +408,8 @@ export function PromptProjectSelector(props: {
/> />
} }
> >
<Menu.Sub> <DropdownMenu.Sub>
<Menu.SubTrigger <DropdownMenu.SubTrigger
id={props.controller.actionKey()} id={props.controller.actionKey()}
data-option-key={props.controller.actionKey()} data-option-key={props.controller.actionKey()}
class={projectActionClass} class={projectActionClass}
@@ -419,23 +419,24 @@ export function PromptProjectSelector(props: {
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())} onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
> >
<Icon name="plus" size="small" /> <Icon name="plus" size="small" />
<span class="min-w-0 flex-1 truncate leading-5"> <span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
{props.controller.labels.add()} {props.controller.labels.add()}
</span> </span>
</Menu.SubTrigger> <Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Menu.Portal> </DropdownMenu.SubTrigger>
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none"> <DropdownMenu.Portal>
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}> <For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />} {(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For> </For>
</Menu.SubContent> </DropdownMenu.SubContent>
</Menu.Portal> </DropdownMenu.Portal>
</Menu.Sub> </DropdownMenu.Sub>
</Show> </Show>
</div> </div>
</Menu.Content> </DropdownMenu.Content>
</Menu.Portal> </DropdownMenu.Portal>
</Menu> </DropdownMenu>
) )
} }
@@ -506,7 +507,7 @@ function ProjectItem(props: {
}) { }) {
const key = () => props.controller.projectKey(props.project) const key = () => props.controller.projectKey(props.project)
return ( return (
<Menu.RadioItem <DropdownMenu.RadioItem
id={key()} id={key()}
value={key()} value={key()}
data-option-key={key()} data-option-key={key()}
@@ -533,8 +534,11 @@ function ProjectItem(props: {
src={getProjectAvatarSource(props.project.id, props.project.icon)} src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)} variant={getProjectAvatarVariant(props.project.icon?.color)}
/> />
<span class="min-w-0 truncate leading-5">{displayName(props.project)}</span> <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
</Menu.RadioItem> <DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
) )
} }
@@ -548,7 +552,7 @@ function ProjectAction(props: {
}) { }) {
const key = () => props.controller.actionKey(props.server) const key = () => props.controller.actionKey(props.server)
return ( return (
<Menu.Item <DropdownMenu.Item
id={key()} id={key()}
data-option-key={key()} data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover" class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
@@ -569,17 +573,17 @@ function ProjectAction(props: {
onSelect={() => props.onSelect(props.server)} onSelect={() => props.onSelect(props.server)}
> >
<Icon name="plus" size="small" /> <Icon name="plus" size="small" />
<span class="min-w-0 truncate leading-5"> <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
{props.controller.labels.add()} {props.controller.labels.add()}
</span> </DropdownMenu.ItemLabel>
</Menu.Item> </DropdownMenu.Item>
) )
} }
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) { function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
return ( return (
<Menu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}> <DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
<span class="min-w-0 flex-1 truncate leading-5">{props.server.name}</span> <DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
</Menu.Item> </DropdownMenu.Item>
) )
} }
@@ -1,8 +1,8 @@
import { createMemo, createSignal, For, Show } from "solid-js" import { createMemo, createSignal, For, Show } from "solid-js"
import { Menu } from "@opencode-ai/ui/menu" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace" import { sameDirectory } from "@/utils/workspace"
@@ -58,7 +58,7 @@ export function PromptWorkspaceSelector(props: {
return ( return (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Tooltip <TooltipV2
placement="top" placement="top"
openDelay={800} openDelay={800}
value={ value={
@@ -77,8 +77,8 @@ export function PromptWorkspaceSelector(props: {
contentClass={props.onboarding ? "max-w-[280px]" : undefined} contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0" class="min-w-0"
> >
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}> <MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu.Trigger <MenuV2.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")} aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted" class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
> >
@@ -92,14 +92,14 @@ export function PromptWorkspaceSelector(props: {
/> />
</Show> </Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" /> <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger> </MenuV2.Trigger>
<Menu.Portal> <MenuV2.Portal>
<Menu.Content class="w-[200px]"> <MenuV2.Content class="w-[200px]">
<Menu.Group> <MenuV2.Group>
<Menu.GroupLabel>{language.t("session.new.workspace.runIn")}</Menu.GroupLabel> <MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<Menu.Item onSelect={() => select("main")}> <MenuV2.Item onSelect={() => select("main")}>
<Icon name="monitor" /> <Icon name="monitor" />
<Tooltip <TooltipV2
placement="right" placement="right"
openDelay={800} openDelay={800}
value={ value={
@@ -113,14 +113,14 @@ export function PromptWorkspaceSelector(props: {
class="min-w-0 flex-1" class="min-w-0 flex-1"
> >
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span> <span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</Tooltip> </TooltipV2>
<Show when={selected() === "main"}> <Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" /> <Icon name="check" size="small" class="shrink-0" />
</Show> </Show>
</Menu.Item> </MenuV2.Item>
<Menu.Item onSelect={() => select("create")}> <MenuV2.Item onSelect={() => select("create")}>
<Icon name="workspace-new" /> <Icon name="workspace-new" />
<Tooltip <TooltipV2
placement="right" placement="right"
openDelay={800} openDelay={800}
value={ value={
@@ -134,25 +134,25 @@ export function PromptWorkspaceSelector(props: {
class="min-w-0 flex-1" class="min-w-0 flex-1"
> >
<span class="min-w-0 truncate">{language.t("workspace.new")}</span> <span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip> </TooltipV2>
<Show when={selected() === "create"}> <Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" /> <Icon name="check" size="small" class="shrink-0" />
</Show> </Show>
</Menu.Item> </MenuV2.Item>
</Menu.Group> </MenuV2.Group>
<Show <Show
when={props.workspaces.length > 0} when={props.workspaces.length > 0}
fallback={ fallback={
<> <>
<Menu.Separator class="h-[0.5px]" /> <MenuV2.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}> <MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span> <span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item> </MenuV2.Item>
</> </>
} }
> >
<Menu.Separator class="h-[0.5px]" /> <MenuV2.Separator class="h-[0.5px]" />
<Menu.Sub <MenuV2.Sub
gutter={0} gutter={0}
overlap overlap
overflowPadding={8} overflowPadding={8}
@@ -166,7 +166,7 @@ export function PromptWorkspaceSelector(props: {
requestAnimationFrame(() => searchInput?.focus()) requestAnimationFrame(() => searchInput?.focus())
}} }}
> >
<Menu.SubTrigger <MenuV2.SubTrigger
onKeyDown={(event) => { onKeyDown={(event) => {
if ( if (
event.key === "ArrowRight" || event.key === "ArrowRight" ||
@@ -181,9 +181,9 @@ export function PromptWorkspaceSelector(props: {
<span class="min-w-0 flex-1 truncate"> <span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span> </span>
</Menu.SubTrigger> </MenuV2.SubTrigger>
<Menu.Portal> <MenuV2.Portal>
<Menu.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto"> <MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<Show when={props.workspaces.length >= 10}> <Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" /> <Icon name="magnifying-glass" size="small" class="shrink-0" />
@@ -211,27 +211,27 @@ export function PromptWorkspaceSelector(props: {
</Show> </Show>
<For each={workspaces()}> <For each={workspaces()}>
{(workspace) => ( {(workspace) => (
<Menu.Item onSelect={() => select(workspace)}> <MenuV2.Item onSelect={() => select(workspace)}>
<Icon name="workspace-isolated" /> <Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span> <span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}> <Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" /> <Icon name="check" size="small" class="shrink-0" />
</Show> </Show>
</Menu.Item> </MenuV2.Item>
)} )}
</For> </For>
<Menu.Separator class="h-[0.5px]" /> <MenuV2.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}> <MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span> <span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item> </MenuV2.Item>
</Menu.SubContent> </MenuV2.SubContent>
</Menu.Portal> </MenuV2.Portal>
</Menu.Sub> </MenuV2.Sub>
</Show> </Show>
</Menu.Content> </MenuV2.Content>
</Menu.Portal> </MenuV2.Portal>
</Menu> </MenuV2>
</Tooltip> </TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" /> <PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
</> </>
) )
@@ -255,7 +255,7 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
return ( return (
<Show when={label()}> <Show when={label()}>
{(value) => ( {(value) => (
<Tooltip <TooltipV2
placement="top" placement="top"
value={value()} value={value()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`} class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
@@ -265,7 +265,7 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" /> <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span> <span class="min-w-0 truncate">{value()}</span>
</div> </div>
</Tooltip> </TooltipV2>
)} )}
</Show> </Show>
) )
@@ -1,7 +1,9 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useQueryClient } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server" import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
@@ -16,7 +18,8 @@ export function createProviderConnectionController(options: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const data = useData() const serverSync = useServerSync()
const queryClient = useQueryClient()
const location = () => { const location = () => {
const directory = options.directory() const directory = options.directory()
return directory ? { directory } : undefined return directory ? { directory } : undefined
@@ -115,15 +118,12 @@ export function createProviderConnectionController(options: {
} }
const finish = async () => { const finish = async () => {
cancelPolling() cancelPolling()
const ref = location() const directory = options.directory()
data.location.integration.invalidate(ref) const key = directory ? pathKey(directory) : null
data.location.provider.invalidate(ref)
data.location.model.invalidate(ref)
await Promise.all([ await Promise.all([
data.location.integration.sync(ref), queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
data.location.provider.sync(ref), queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
data.location.model.sync(ref), ])
]).catch(() => undefined)
if (polling.disposed) return if (polling.disposed) return
options.onComplete() options.onComplete()
} }
@@ -1,6 +1,6 @@
import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Menu } from "@opencode-ai/ui/menu" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -59,19 +59,19 @@ export const ServerRowMenuView: Component<{
const builtin = () => ServerConnection.builtin(props.server) const builtin = () => ServerConnection.builtin(props.server)
const httpServer = () => (props.server.type === "http" ? props.server : undefined) const httpServer = () => (props.server.type === "http" ? props.server : undefined)
return ( return (
<Menu gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}> <MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<Menu.Trigger <MenuV2.Trigger
as={IconButton} as={IconButtonV2}
variant="ghost-muted" variant="ghost-muted"
size="small" size="small"
icon={<Icon name="outline-dots" />} icon={<IconV2 name="outline-dots" />}
aria-label={props.labels.more} aria-label={props.labels.more}
/> />
<Menu.Portal> <MenuV2.Portal>
<Menu.Content> <MenuV2.Content>
<Menu.Group> <MenuV2.Group>
<Menu.GroupLabel>{props.labels.server}</Menu.GroupLabel> <MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
<Menu.Item <MenuV2.Item
disabled={builtin() || !httpServer()} disabled={builtin() || !httpServer()}
onSelect={() => { onSelect={() => {
const server = httpServer() const server = httpServer()
@@ -79,20 +79,20 @@ export const ServerRowMenuView: Component<{
}} }}
> >
{props.labels.edit} {props.labels.edit}
</Menu.Item> </MenuV2.Item>
<Show when={props.canDefault && !props.isDefault}> <Show when={props.canDefault && !props.isDefault}>
<Menu.Item onSelect={props.onSetDefault}>{props.labels.default}</Menu.Item> <MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
</Show> </Show>
<Show when={props.canDefault && props.isDefault}> <Show when={props.canDefault && props.isDefault}>
<Menu.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</Menu.Item> <MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
</Show> </Show>
<Show when={props.canRemove}> <Show when={props.canRemove}>
<Menu.Separator /> <MenuV2.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item> <MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
</Show> </Show>
</Menu.Group> </MenuV2.Group>
</Menu.Content> </MenuV2.Content>
</Menu.Portal> </MenuV2.Portal>
</Menu> </MenuV2>
) )
} }
@@ -64,7 +64,6 @@ export function ServerRow(props: ServerRowProps) {
return ( return (
<Tooltip <Tooltip
appearance="standard"
class="flex-1 min-w-0" class="flex-1 min-w-0"
value={tooltipValue()} value={tooltipValue()}
contentStyle={{ "max-width": "none", "white-space": "nowrap" }} contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
@@ -1,21 +1,23 @@
import { Show, createMemo, type ComponentProps, type JSX } from "solid-js" import { Show, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { IconButton } from "@opencode-ai/ui/icon-button" import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useData } from "@/context/server" import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useWorkspaceLocation } from "@/context/location" import { useSDK } from "@/context/sdk"
import { getSessionContext } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
placement?: ComponentProps<typeof Tooltip>["placement"] placement?: ComponentProps<typeof TooltipV2>["placement"]
} }
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
@@ -38,11 +40,11 @@ function openSessionContext(args: {
} }
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
const data = useData() const sync = useSync()
const file = useFile() const file = useFile()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const sdk = useWorkspaceLocation() const sdk = useSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -54,8 +56,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => isDesktop() && !!params.id, fileBrowser: () => isDesktop() && !!params.id,
}) })
const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : [])) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const usd = createMemo( const usd = createMemo(
() => () =>
@@ -65,21 +67,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const context = createMemo(() => { const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0) return usd().format(info()?.cost ?? 0)
}) })
@@ -110,21 +98,24 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => ( const circle = () => (
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<ProgressCircle <ProgressCircle
appearance="indicator"
size={16} size={16}
strokeWidth={2} strokeWidth={2}
percentage={context()?.usage ?? 0} percentage={context()?.usage ?? 0}
style={{ style={
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))", variant() === "indicator"
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)", ? {
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))", "--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
}} "--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}
: undefined
}
/> />
</div> </div>
) )
const circleV2 = () => ( const circleV2 = () => (
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<ProgressCircle appearance="compact" percentage={context()?.usage ?? 0} /> <ProgressCircleV2 percentage={context()?.usage ?? 0} />
</div> </div>
) )
@@ -141,11 +132,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return ( return (
<Show when={params.id}> <Show when={params.id}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}> <TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Show <Show
when={variant() === "indicator"} when={variant() === "indicator"}
fallback={ fallback={
<IconButton <IconButtonV2
type="button" type="button"
variant="ghost-muted" variant="ghost-muted"
size="large" size="large"
@@ -157,7 +148,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
> >
{circle()} {circle()}
</Show> </Show>
</Tooltip> </TooltipV2>
</Show> </Show>
) )
} }
@@ -1,12 +1,12 @@
import { Menu } from "@opencode-ai/ui/menu" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js" import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/types" import type { Project } from "@/types"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server" import { useServerSync } from "@/context/server-sync"
import { useSettingsDialog } from "@/components/settings-dialog" import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@@ -17,7 +17,7 @@ export function SessionWorkspaceMenu(props: {
sessionID: string sessionID: string
project: Project project: Project
directory: string directory: string
placement?: ComponentProps<typeof Menu>["placement"] placement?: ComponentProps<typeof MenuV2>["placement"]
gutter?: number gutter?: number
class?: string class?: string
contentClass?: string contentClass?: string
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const data = useData() const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces") const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project)) const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running" const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory)) const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () => const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory)) directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
@@ -71,57 +71,57 @@ export function SessionWorkspaceMenu(props: {
} }
return ( return (
<Menu <MenuV2
placement={props.placement ?? "bottom-end"} placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4} gutter={props.gutter ?? 4}
modal={false} modal={false}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
> >
<Menu.Trigger class={props.class} disabled={blocked()}> <MenuV2.Trigger class={props.class} disabled={blocked()}>
{props.children} {props.children}
</Menu.Trigger> </MenuV2.Trigger>
<Menu.Portal> <MenuV2.Portal>
<Menu.Content class={`w-[200px] ${props.contentClass ?? ""}`}> <MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<Menu.Group> <MenuV2.Group>
<Menu.GroupLabel>{language.t("workspace.move.menu.title")}</Menu.GroupLabel> <MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}> <Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}> <MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" /> <Icon name="monitor" />
{language.t("session.new.workspace.local")} {language.t("session.new.workspace.local")}
</Menu.Item> </MenuV2.Item>
</Show> </Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}> <MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" /> <Icon name="workspace-new" />
{language.t("workspace.new")} {language.t("workspace.new")}
</Menu.Item> </MenuV2.Item>
<Show when={workspaces().length > 0}> <Show when={workspaces().length > 0}>
<Menu.Sub gutter={0} overlap overflowPadding={8}> <MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<Menu.SubTrigger> <MenuV2.SubTrigger>
<Icon name="workspace-isolated" /> <Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</Menu.SubTrigger> </MenuV2.SubTrigger>
<Menu.Portal> <MenuV2.Portal>
<Menu.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto"> <MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<For each={workspaces()}> <For each={workspaces()}>
{(workspace) => ( {(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}> <MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" /> <Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span> <span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item> </MenuV2.Item>
)} )}
</For> </For>
</Menu.SubContent> </MenuV2.SubContent>
</Menu.Portal> </MenuV2.Portal>
</Menu.Sub> </MenuV2.Sub>
</Show> </Show>
</Menu.Group> </MenuV2.Group>
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" /> <MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<Menu.Item onSelect={() => openWorkspaces()}> <MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span> <span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item> </MenuV2.Item>
</Menu.Content> </MenuV2.Content>
</Menu.Portal> </MenuV2.Portal>
</Menu> </MenuV2>
) )
} }
@@ -2,9 +2,10 @@ import { For, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon" import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Menu } from "@opencode-ai/ui/menu" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "@opencode-ai/ui/split-button" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app" import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
@@ -14,13 +15,13 @@ export function OpenInAppV2(props: { directory: () => string }) {
return ( return (
<Show when={props.directory() && state.canOpen()}> <Show when={props.directory() && state.canOpen()}>
<SplitButton class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}> <SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
<Tooltip <TooltipV2
placement="bottom" placement="bottom"
value={language.t("session.header.open.ariaLabel", { app: state.current().label })} value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
class="flex items-center" class="flex items-center"
> >
<SplitButtonAction <SplitButtonV2Action
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -33,28 +34,28 @@ export function OpenInAppV2(props: { directory: () => string }) {
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}> <Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Spinner class="size-3.5" /> <Spinner class="size-3.5" />
</Show> </Show>
</SplitButtonAction> </SplitButtonV2Action>
</Tooltip> </TooltipV2>
<Menu <MenuV2
gutter={4} gutter={4}
modal={false} modal={false}
placement="bottom-end" placement="bottom-end"
open={state.menu.open} open={state.menu.open}
onOpenChange={(open) => state.setMenu("open", open)} onOpenChange={(open) => state.setMenu("open", open)}
> >
<Menu.Trigger <MenuV2.Trigger
as={SplitButtonMenuTrigger} as={SplitButtonV2MenuTrigger}
disabled={state.opening()} disabled={state.opening()}
aria-label={language.t("session.header.open.menu")} aria-label={language.t("session.header.open.menu")}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
<Icon name="chevron-down" size="small" /> <IconV2 name="chevron-down" size="small" />
</Menu.Trigger> </MenuV2.Trigger>
<Menu.Portal> <MenuV2.Portal>
<Menu.Content class="open-in-app-v2-menu"> <MenuV2.Content class="open-in-app-v2-menu">
<Menu.Group> <MenuV2.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel> <MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
<Menu.RadioGroup <MenuV2.RadioGroup
value={state.current().id} value={state.current().id}
onChange={(value) => { onChange={(value) => {
state.selectApp(value as OpenApp) state.selectApp(value as OpenApp)
@@ -62,7 +63,7 @@ export function OpenInAppV2(props: { directory: () => string }) {
> >
<For each={state.options()}> <For each={state.options()}>
{(option) => ( {(option) => (
<Menu.RadioItem <MenuV2.RadioItem
value={option.id} value={option.id}
disabled={state.opening()} disabled={state.opening()}
onSelect={() => { onSelect={() => {
@@ -73,13 +74,13 @@ export function OpenInAppV2(props: { directory: () => string }) {
> >
<AppIcon id={option.icon} /> <AppIcon id={option.icon} />
{option.label} {option.label}
</Menu.RadioItem> </MenuV2.RadioItem>
)} )}
</For> </For>
</Menu.RadioGroup> </MenuV2.RadioGroup>
</Menu.Group> </MenuV2.Group>
<Menu.Separator /> <MenuV2.Separator />
<Menu.Item <MenuV2.Item
onSelect={() => { onSelect={() => {
state.setMenu("open", false) state.setMenu("open", false)
state.copyPath() state.copyPath()
@@ -87,11 +88,11 @@ export function OpenInAppV2(props: { directory: () => string }) {
> >
<Icon name="copy" size="small" class="text-icon-weak" /> <Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")} {language.t("session.header.open.copyPath")}
</Menu.Item> </MenuV2.Item>
</Menu.Content> </MenuV2.Content>
</Menu.Portal> </MenuV2.Portal>
</Menu> </MenuV2>
</SplitButton> </SplitButtonV2>
</Show> </Show>
) )
} }
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
return {
id,
role: "user",
time: { created: 1 },
} as unknown as Message
}
const assistant = (id: string) => {
return {
id,
role: "assistant",
time: { created: 1 },
} as unknown as Message
}
describe("estimateSessionContextBreakdown", () => {
test("estimates tokens and keeps remaining tokens as other", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 20,
systemPrompt: "system prompt",
})
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
expect(map.system).toBe(4)
expect(map.user).toBe(3)
expect(map.assistant).toBe(5)
expect(map.other).toBe(8)
})
test("scales segments when estimates exceed input", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 10,
systemPrompt: "z".repeat(200),
})
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
expect(total).toBeLessThanOrEqual(10)
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
})
})
@@ -0,0 +1,132 @@
import type { Message, Part } from "@/types"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
export type SessionContextBreakdownSegment = {
key: SessionContextBreakdownKey
tokens: number
width: number
percent: number
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: Part) => {
if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0
return 0
}
const charsFromAssistantPart = (part: Part) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
const input = Object.keys(part.state.input).length * 16
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
return { assistant: 0, tool: input }
}
const build = (
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
input: number,
) => {
return [
{
key: "system",
tokens: tokens.system,
},
{
key: "user",
tokens: tokens.user,
},
{
key: "assistant",
tokens: tokens.assistant,
},
{
key: "tool",
tokens: tokens.tool,
},
{
key: "other",
tokens: tokens.other,
},
]
.filter((x) => x.tokens > 0)
.map((x) => ({
key: x.key,
tokens: x.tokens,
width: toPercent(x.tokens, input),
percent: toPercentLabel(x.tokens, input),
})) as SessionContextBreakdownSegment[]
}
export function estimateSessionContextBreakdown(args: {
messages: Message[]
parts: Record<string, Part[] | undefined>
input: number
systemPrompt?: string
}) {
if (!args.input) return []
const counts = args.messages.reduce(
(acc, msg) => {
const parts = args.parts[msg.id] ?? []
if (msg.role === "user") {
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
return { ...acc, user: acc.user + user }
}
if (msg.role !== "assistant") return acc
const assistant = parts.reduce(
(sum, part) => {
const next = charsFromAssistantPart(part)
return {
assistant: sum.assistant + next.assistant,
tool: sum.tool + next.tool,
}
},
{ assistant: 0, tool: 0 },
)
return {
...acc,
assistant: acc.assistant + assistant.assistant,
tool: acc.tool + assistant.tool,
}
},
{
system: args.systemPrompt?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
},
)
const tokens = {
system: estimateTokens(counts.system),
user: estimateTokens(counts.user),
assistant: estimateTokens(counts.assistant),
tool: estimateTokens(counts.tool),
}
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
if (estimated <= args.input) {
return build({ ...tokens, other: args.input - estimated }, args.input)
}
const scale = args.input / estimated
const scaled = {
system: Math.floor(tokens.system * scale),
user: Math.floor(tokens.user * scale),
assistant: Math.floor(tokens.assistant * scale),
tool: Math.floor(tokens.tool * scale),
}
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
}
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@/types"
import { getSessionContext } 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("getSessionContext", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 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 ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.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 ctx = getSessionContext(messages, providers)
expect(ctx?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull()
})
test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }]
const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContext(messages, providers)
expect(one?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2")
})
test("returns undefined when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined)
expect(ctx).toBeUndefined()
})
})
@@ -0,0 +1,65 @@
import type { AssistantMessage, Message } from "@/types"
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
total: number
usage: number | null
}
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[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages)
if (!message) return 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 {
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
@@ -1,7 +1,8 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useData } from "@/context/server" import { useSync } from "@/context/sync"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -10,16 +11,25 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, Part, UserMessage } from "@/types"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useWorkspaceLocation } from "@/context/location" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
assistant: "var(--syntax-property)",
tool: "var(--syntax-warning)",
other: "var(--syntax-comment)",
}
function Stat(props: { label: string; value: JSX.Element }) { function Stat(props: { label: string; value: JSX.Element }) {
return ( return (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -29,11 +39,12 @@ function Stat(props: { label: string; value: JSX.Element }) {
) )
} }
function RawMessageContent(props: { message: SessionMessageInfo; onRendered: () => void }) { function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
const file = createMemo(() => { const file = createMemo(() => {
const contents = JSON.stringify(props.message, null, 2) const parts = props.getParts(props.message.id)
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
return { return {
name: `${props.message.type}-${props.message.id}.json`, name: `${props.message.role}-${props.message.id}.json`,
contents, contents,
cacheKey: checksum(contents), cacheKey: checksum(contents),
} }
@@ -51,7 +62,8 @@ function RawMessageContent(props: { message: SessionMessageInfo; onRendered: ()
} }
function RawMessage(props: { function RawMessage(props: {
message: SessionMessageInfo message: Message
getParts: (id: string) => Part[]
onRendered: () => void onRendered: () => void
time: (value: number | undefined) => string time: (value: number | undefined) => string
}) { }) {
@@ -61,7 +73,7 @@ function RawMessage(props: {
<Accordion.Trigger> <Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full"> <div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate"> <div class="min-w-0 truncate">
{props.message.type} <span class="text-text-base"> {props.message.id}</span> {props.message.role} <span class="text-text-base"> {props.message.id}</span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div> <div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
@@ -72,35 +84,52 @@ function RawMessage(props: {
</StickyAccordionHeader> </StickyAccordionHeader>
<Accordion.Content class="bg-background-base"> <Accordion.Content class="bg-background-base">
<div class="p-3"> <div class="p-3">
<RawMessageContent message={props.message} onRendered={props.onRendered} /> <RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
</div> </div>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
) )
} }
const emptyMessages: SessionMessageInfo[] = [] const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() { export function SessionContextTab() {
const data = useData() const sync = useSync()
const language = useLanguage() const language = useLanguage()
const sdk = useWorkspaceLocation() const sdk = useSDK()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const messages = createMemo( const messages = createMemo(
() => { () => {
const id = params.id const id = params.id
if (!id) return emptyMessages if (!id) return emptyMessages
return data.session.message.list(id) return (sync().data.message[id] ?? []) as Message[]
}, },
emptyMessages, emptyMessages,
{ equals: same }, { equals: same },
) )
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.intl(), { new Intl.NumberFormat(language.intl(), {
@@ -109,28 +138,7 @@ export function SessionContextTab() {
}), }),
) )
const ctx = createMemo(() => { const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const provider = providers.all().get(message.model.providerID)
const model = provider?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
message,
tokens: message.tokens,
providerLabel: provider?.name ?? message.model.providerID,
modelLabel: model?.name ?? message.model.id,
limit: model?.limit.context,
input: message.tokens.input,
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => { const cost = createMemo(() => {
@@ -139,8 +147,8 @@ export function SessionContextTab() {
const counts = createMemo(() => { const counts = createMemo(() => {
const all = messages() const all = messages()
const user = all.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0) const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0) const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return { return {
all: all.length, all: all.length,
user, user,
@@ -149,7 +157,8 @@ export function SessionContextTab() {
}) })
const systemPrompt = createMemo(() => { const systemPrompt = createMemo(() => {
const system = messages().findLast((message) => message.type === "system")?.text const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return if (!system) return
const trimmed = system.trim() const trimmed = system.trim()
if (!trimmed) return if (!trimmed) return
@@ -168,6 +177,30 @@ export function SessionContextTab() {
return c.modelLabel return c.modelLabel
}) })
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: messages(),
parts: sync().data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
})
},
),
)
const breakdownLabel = (key: SessionContextBreakdownKey) => {
if (key === "system") return language.t("context.breakdown.system")
if (key === "user") return language.t("context.breakdown.user")
if (key === "assistant") return language.t("context.breakdown.assistant")
if (key === "tool") return language.t("context.breakdown.tool")
return language.t("context.breakdown.other")
}
const stats = [ const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
@@ -177,11 +210,12 @@ export function SessionContextTab() {
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.tokens.output) }, { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) }, { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{ {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`, value: () =>
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -196,7 +230,7 @@ export function SessionContextTab() {
try { try {
const data = await fetchSessionExport({ const data = await fetchSessionExport({
sessionID, sessionID,
api: serverSDK.api, api: sdk().api,
}) })
const filename = sessionExportFilename(data.info) const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data) downloadSessionExport(filename, data)
@@ -218,6 +252,8 @@ export function SessionContextTab() {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
const restoreScroll = () => { const restoreScroll = () => {
const el = scroll const el = scroll
if (!el) return if (!el) return
@@ -278,6 +314,37 @@ export function SessionContextTab() {
</For> </For>
</div> </div>
<Show when={breakdown().length > 0}>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
<For each={breakdown()}>
{(segment) => (
<div
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": BREAKDOWN_COLOR[segment.key],
}}
/>
)}
</For>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
</div>
)}
</For>
</div>
<div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
</div>
</Show>
<Show when={systemPrompt()}> <Show when={systemPrompt()}>
{(prompt) => ( {(prompt) => (
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -304,7 +371,9 @@ export function SessionContextTab() {
</div> </div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={messages()}>
{(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />} {(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}
</For> </For>
</Accordion> </Accordion>
</div> </div>
@@ -1,3 +1,4 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
@@ -6,10 +7,10 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { StatusPopoverV2 } from "../status-popover" import { StatusPopoverV2 } from "../status-popover"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Keybind } from "@opencode-ai/ui/keybind" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind" import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar" import { useTitlebarRightMount } from "../titlebar"
@@ -59,24 +60,24 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return ( return (
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={props.state.statusVisible}> <Show when={props.state.statusVisible}>
<Tooltip appearance="standard" placement="bottom" value={props.state.statusLabel}> <Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 /> <StatusPopoverV2 />
</Tooltip> </Tooltip>
</Show> </Show>
<Show when={props.state.reviewVisible}> <Show when={props.state.reviewVisible}>
<Tooltip <TooltipV2
class="shrink-0" class="shrink-0"
placement="bottom" placement="bottom"
value={ value={
<> <>
{props.state.reviewLabel} {props.state.reviewLabel}
<Show when={props.state.reviewKeybind.length > 0}> <Show when={props.state.reviewKeybind.length > 0}>
<Keybind keys={props.state.reviewKeybind} variant="neutral" /> <KeybindV2 keys={props.state.reviewKeybind} variant="neutral" />
</Show> </Show>
</> </>
} }
> >
<IconButton <IconButtonV2
type="button" type="button"
variant="ghost-muted" variant="ghost-muted"
size="large" size="large"
@@ -86,9 +87,9 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
aria-label={props.state.reviewLabel} aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened} aria-expanded={props.state.reviewOpened}
aria-controls="review-panel" aria-controls="review-panel"
icon={<Icon name="sidebar-right" />} icon={<IconV2 name="sidebar-right" />}
/> />
</Tooltip> </TooltipV2>
</Show> </Show>
</div> </div>
) )

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