Compare commits

..

45 Commits

Author SHA1 Message Date
Frank
24a9841322 zen: update sst version 2026-02-18 13:54:23 -05:00
Dax
8bf06cbcc1 refactor: migrate src/global/index.ts from Bun.file() to Filesystem module (#14146) 2026-02-18 13:25:18 -05:00
OpeOginni
be2e6f1926 fix(opencode): update pasteImage to only increment count when the previous attachment is an image too (#14173) 2026-02-18 12:09:09 -06:00
Adam
72c12d59af chore: cleanup 2026-02-18 11:55:08 -06:00
Adam
8408e4702e chore: cleanup 2026-02-18 11:48:25 -06:00
Adam
ef14f64f9e chore: cleanup 2026-02-18 11:48:25 -06:00
Adam
3f60a6c2a4 chore: cleanup 2026-02-18 11:48:25 -06:00
Matt Silverlock
d447b7694a fix(github): emit PROMPT_TOO_LARGE error on context overflow (#14166) 2026-02-18 11:45:27 -06:00
Dax
5638b782c5 refactor: migrate editor.ts from Bun.file()/Bun.write() to Filesystem module (#14149) 2026-02-18 17:44:33 +00:00
Dax
eb3f337695 refactor: migrate clipboard.ts from Bun.file() to Filesystem module (#14148) 2026-02-18 12:38:44 -05:00
Dax
c88ff3c08b refactor: migrate src/bun/index.ts from Bun.file()/Bun.write() to Filesystem module (#14147) 2026-02-18 12:38:30 -05:00
Dax
e0e8b94384 refactor: migrate uninstall.ts from Bun.file()/Bun.write() to Filesystem module (#14150) 2026-02-18 12:35:55 -05:00
Dax
8f4a72c57a refactor: migrate config/markdown.ts from Bun.file() to Filesystem module (#14151) 2026-02-18 12:35:32 -05:00
Dax
ef155f3766 refactor: migrate file/index.ts from Bun.file() to Filesystem module (#14152) 2026-02-18 12:33:20 -05:00
Dax
82a323ef70 refactor: migrate cli/cmd/github.ts from Bun.write() to Filesystem module (#14154) 2026-02-18 12:33:05 -05:00
Dax
a500eaa2d4 refactor: migrate format/formatter.ts from Bun.file() to Filesystem module (#14153) 2026-02-18 12:31:35 -05:00
Dax Raad
b714bb21d2 ci: switch to standard GitHub cache action for Bun dependencies 2026-02-18 12:24:17 -05:00
Dax
472d01fbaf refactor: migrate cli/cmd/run.ts from Bun.file() to Filesystem/stat modules (#14155) 2026-02-18 17:22:46 +00:00
Dax Raad
a5c15a23e4 core: allow readJson to be called without explicit type parameter
Added default type parameter 'any' to readJson<T> so users can call it without specifying a type when they don't need strict typing. This reduces boilerplate for quick JSON reads where type safety isn't required.
2026-02-18 12:21:25 -05:00
Dax
3d189b42a3 refactor: migrate file/ripgrep.ts from Bun.file()/Bun.write() to Filesystem module (#14159) 2026-02-18 12:10:42 -05:00
opencode-agent[bot]
91a3ee642d chore: update nix node_modules hashes 2026-02-18 17:08:31 +00:00
Dax
37b24f4870 refactor: migrate index.ts from Bun.file() to Filesystem module (#14160) 2026-02-18 12:03:24 -05:00
Alexander Abramov
38572b8175 feat: add Julia language server support (#14129)
Co-authored-by: Alexander Abramov <abramov231@gmail.com>
2026-02-18 11:02:29 -06:00
Aiden Cline
fc1addb8f4 ignore: tweak contributing md (#14168) 2026-02-18 11:01:59 -06:00
Vladimir Glafirov
83b7d8e04c feat: GitLab Duo - bump gitlab-ai-provider to 3.6.0 (adds Sonnet 4.6) (#14115) 2026-02-18 10:58:38 -06:00
Mike Harris
d27dbfe062 fix(cli): session list --max-count not honored, shows too few sessions (#14162) 2026-02-18 10:56:37 -06:00
Brendan Allan
f8904e3972 desktop: handle sidecar key in projectsKey 2026-02-19 00:05:50 +08:00
Brendan Allan
4a5823562c desktop: fix isLocal 2026-02-19 00:03:08 +08:00
opencode-agent[bot]
3aaf29b693 chore: update nix node_modules hashes 2026-02-18 15:41:20 +00:00
Dax
6b29896a35 feat: Add centralized filesystem module for Bun.file migration (#14117) 2026-02-18 15:30:52 +00:00
Brendan Allan
1bb8574179 app: refactor server management backend (#13813) 2026-02-18 23:03:24 +08:00
Adam
2611c35acc fix(app): lower threshold for diff hiding 2026-02-18 08:40:05 -06:00
Adam
00c238777a chore: cleanup (#14113) 2026-02-18 08:26:15 -06:00
Dax Raad
e4b548fa76 docs: add policy about AI-generated security reports
We receive a large number of AI-generated security reports and don't have the resources to review them all. This policy clarifies that such submissions will result in an automatic ban to protect our maintainers' time.
2026-02-18 09:15:18 -05:00
Adam
e132dd2c70 chore: cleanup 2026-02-18 07:22:36 -06:00
David Hill
fbe9669c57 fix: use group-hover for file tree icon color swap at all nesting levels 2026-02-18 13:20:02 +00:00
Adam
c34ad7223a chore: cleanup 2026-02-18 07:12:54 -06:00
David Hill
cc86a64bb5 tui: simplify mode toggle icon styling
Use consistent strong color for active mode icons instead of different
colors for shell vs normal mode, making the active state more visually
clear to users.
2026-02-18 12:35:28 +00:00
Adam
3394402aef chore: cleanup 2026-02-18 06:32:35 -06:00
Brendan Allan
6cd3a59022 desktop: cleanup 2026-02-18 16:24:28 +08:00
Brendan Allan
5aeb305344 desktop: temporarily disable wsl 2026-02-18 16:10:07 +08:00
Caleb Norton
6eb043aedb ci: allow commits on top of beta PRs (#11924) 2026-02-18 00:20:05 -06:00
Salam Elbilig
e96f6385c2 fix(opencode): fix Clojure syntax highlighting (#13453)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-18 00:18:18 -06:00
Jérôme Benoit
1109a282e0 ci: add nix-eval workflow for cross-platform flake evaluation (#12175) 2026-02-18 00:03:37 -06:00
Aiden Cline
25f3eef957 fix: ensure explore subagent has external_directory perm set to ask instead of auto denying (#14060) 2026-02-17 20:16:55 -06:00
131 changed files with 4477 additions and 2466 deletions

View File

@@ -3,12 +3,13 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
steps:
- name: Mount Bun Cache
if: ${{ runner.os == 'Linux' }}
uses: useblacksmith/stickydisk@v1
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
path: ~/.bun
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -1,46 +0,0 @@
name: nix-desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
nix-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L

95
.github/workflows/nix-eval.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: nix-eval
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
nix-eval:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
set -euo pipefail
nix --version
echo "=== Flake metadata ==="
nix flake metadata
echo ""
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
echo ""
echo "=== Evaluating packages for all systems ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for packages.$system.$pkg"
echo "$output"
exit 1
fi
done
done
echo ""
echo "=== Evaluating optional packages ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $OPTIONAL_PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::warning::Evaluation failed for packages.$system.$pkg"
echo "$output"
fi
done
done
echo ""
echo "=== Evaluating devShells for all systems ==="
for system in $SYSTEMS; do
printf "%s: " "$system"
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for devShells.$system.default"
echo "$output"
exit 1
fi
done
echo ""
echo "=== All evaluations passed ==="

View File

@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev]
branches: [dev, beta]
paths:
- "bun.lock"
- "package.json"

9
.zed/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}

View File

@@ -24,6 +24,11 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
## Adding New Providers
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
## Developing OpenCode
- Requirements: Bun 1.3+

View File

@@ -1,5 +1,11 @@
# Security
## IMPORTANT
We do not accept AI generated security reports. We receive a large number of
these and we absolutely do not have the resources to review them all. If you
submit one that will be an automatic ban from the project.
## Threat Model
### Overview

117
bun.lock
View File

@@ -14,10 +14,11 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.17.23",
"sst": "3.18.10",
"turbo": "2.5.6",
},
},
@@ -288,7 +289,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.5.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -324,6 +325,7 @@
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
@@ -356,6 +358,7 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
@@ -989,7 +992,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-I8+EGdUeKmGJSjAdFobHtqpxM9Fm00w0j7NJbtln/D/XQ1SKEGoZIuqJko4v0pV2mkhGUIs7qezljH/2kbXovA=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
@@ -1609,7 +1612,7 @@
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="],
"@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
"@smithy/core": ["@smithy/core@3.23.2", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
@@ -1639,9 +1642,9 @@
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.14", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.16", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.31", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.33", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="],
@@ -1665,7 +1668,7 @@
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.3", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.5", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ=="],
"@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
@@ -1681,9 +1684,9 @@
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.30", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.32", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.33", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.35", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="],
@@ -1917,6 +1920,8 @@
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
@@ -1983,7 +1988,7 @@
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5l51HlXjX7lXwo65DEl1IaCFLjmkMtL6K3NrSEamPNeNTtTQwZRa3pQ9V65dCglnnCQ0M3+VF1RqzC7FU0iDKg=="],
"@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="],
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="],
@@ -2049,7 +2054,7 @@
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -2131,11 +2136,11 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"b4a": ["b4a@1.7.4", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug=="],
"b4a": ["b4a@1.7.5", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
@@ -2211,13 +2216,13 @@
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eDgLN9teKTfmvrCqgwwmWNsNszxYs7IZdCqk0S1DCarvMhr4wcajoSBlA/nQA0/owwLduPTS8xxCnQp4/N/gDg=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-X+PjwJUWenUmdQBP8EtdItMyieQ6Nlpn+BH518oaouDiSnWj5+b0Y7DNDZJq7Ezom4EaxmqL/uGYZK3aCQ7CXg=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zMLs2YIGB+/jxrYFXaFhVKX/GBt05UTF45lc9srcHc9JXGjEj+12CIo1CHLTAWatXMTqt0Jsu6ukWEoWVT/ayA=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -2237,7 +2242,7 @@
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -2471,7 +2476,7 @@
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
@@ -2707,7 +2712,7 @@
"h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="],
"happy-dom": ["happy-dom@20.6.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^6.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ=="],
"happy-dom": ["happy-dom@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
@@ -2919,7 +2924,7 @@
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
@@ -3497,7 +3502,7 @@
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
@@ -3759,23 +3764,23 @@
"srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="],
"sst": ["sst@3.17.23", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.23", "sst-darwin-x64": "3.17.23", "sst-linux-arm64": "3.17.23", "sst-linux-x64": "3.17.23", "sst-linux-x86": "3.17.23", "sst-win32-arm64": "3.17.23", "sst-win32-x64": "3.17.23", "sst-win32-x86": "3.17.23" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-TwKgUgDnZdc1Swe+bvCNeyO4dQnYz5cTodMpYj3jlXZdK9/KNz0PVxT1f0u5E76i1pmilXrUBL/f7iiMPw4RDg=="],
"sst": ["sst@3.18.10", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.18.10", "sst-darwin-x64": "3.18.10", "sst-linux-arm64": "3.18.10", "sst-linux-x64": "3.18.10", "sst-linux-x86": "3.18.10", "sst-win32-arm64": "3.18.10", "sst-win32-x64": "3.18.10", "sst-win32-x86": "3.18.10" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-SY+ldeJ9K5E9q+DhjXA3e2W3BEOzBwkE3IyLSD71uA3/5nRhUAST31iOWEpW36LbIvSQ9uOVDFcebztoLJ8s7w=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.23", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R6kvmF+rUideOoU7KBs2SdvrIupoE+b+Dor/eq9Uo4Dojj7KvYDZI/EDm8sSCbbcx/opiWeyNqKtlnLEdCxE6g=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.18.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3MwIpMZhhdZKDqLp9ZQNlwkWix5+q+N0PWstuTomYwgZOxCCe6u9IIsoIszSk+GAJJN/jvGZyLiXKeV4iiQvw=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.23", "", { "os": "darwin", "cpu": "x64" }, "sha512-WW4P1S35iYCifQXxD+sE3wuzcN+LHLpuKMaNoaBqEcWGZnH3IPaDJ7rpLF0arkDAo/z3jZmWWzOCkr0JuqJ8vQ=="],
"sst-darwin-x64": ["sst-darwin-x64@3.18.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-nQ0jMKkPOa+kj6Ygz8+kYhBua/vgNTLkd+4r8NSmk7v+Zs78lKnx3T//kEzS0yik6Q6QwGfokwrTcA1Jii2xSw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjtNqgIh7RlAWgPLFCAt0mXvIB+J7WjmRvIRrAdX0mXsndOiBJ/DMOgXSLVsIWHCfPj8MIEot/hWpnJgXgIeag=="],
"sst-linux-arm64": ["sst-linux-arm64@3.18.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-mj9VNj3SvLS+HaXx2PhCX0aTA7CwJNoM6JhRc0s/zCilqchcvqDjbhpYBJO4brEPv6aOaaa7T3WvIQqtYauK4Q=="],
"sst-linux-x64": ["sst-linux-x64@3.17.23", "", { "os": "linux", "cpu": "x64" }, "sha512-qdqJiEbYfCjZlI3F/TA6eoIU7JXVkEEI/UMILNf2JWhky0KQdCW2Xyz+wb6c0msVJCWdUM/uj+1DaiP2eXvghw=="],
"sst-linux-x64": ["sst-linux-x64@3.18.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7iy1Eq2eqnT9Ag/8OVgC04vRjV7AAQyf/BvzLc+6Sz+GvRiKA8VEuPnbXNYQF+NIvEqsawfcd7MknSTtImpsvQ=="],
"sst-linux-x86": ["sst-linux-x86@3.17.23", "", { "os": "linux", "cpu": "none" }, "sha512-aGmUujIvoNlmAABEGsOgfY1rxD9koC6hN8bnTLbDI+oI/u/zjHYh50jsbL0p3TlaHpwF/lxP3xFSuT6IKp+KgA=="],
"sst-linux-x86": ["sst-linux-x86@3.18.10", "", { "os": "linux", "cpu": "none" }, "sha512-77qZSuPZeQ5bdRCiq1pQEdY8EcGNHboKrx4P2yFid2FBDKJsXxOXtIxJdloyx+ljBn0+nxl/g040QBmXxdc9tA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.23", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZxdkGqYDrrZGz98rijDCN+m5yuCcwD6Bc9/6hubLsvdpNlVorUqzpg801Ec97xSK0nIC9g6pNiRyxAcsQQstUg=="],
"sst-win32-arm64": ["sst-win32-arm64@3.18.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aY+FhMxvYs8crlrKALpLn/kKmud8YQj6LkMHsrOAAIJhfNyxhCja2vrYQaY+bcqdsS5W2LMVcS2hyaMqKXZKcg=="],
"sst-win32-x64": ["sst-win32-x64@3.17.23", "", { "os": "win32", "cpu": "x64" }, "sha512-yc9cor4MS49Ccy2tQCF1tf6M81yLeSGzGL+gjhUxpVKo2pN3bxl3w70eyU/mTXSEeyAmG9zEfbt6FNu4sy5cUA=="],
"sst-win32-x64": ["sst-win32-x64@3.18.10", "", { "os": "win32", "cpu": "x64" }, "sha512-rY+yJXOpG+P5xXnaQRpCvBK2zwwLhjzpYidGkp6F+cGgiVdh2Wre/CIQNRaVHr20ncj8lLe/RsHWa9QCNM48jg=="],
"sst-win32-x86": ["sst-win32-x86@3.17.23", "", { "os": "win32", "cpu": "none" }, "sha512-DIp3s54IpNAfdYjSRt6McvkbEPQDMxUu6RUeRAd2C+FcTJgTloon/ghAPQBaDgu2VoVgymjcJARO/XyfKcCLOQ=="],
"sst-win32-x86": ["sst-win32-x86@3.18.10", "", { "os": "win32", "cpu": "none" }, "sha512-pq8SmV0pIjBFMY6DraUZ4akyTxHnfjIKCRbBLdMxFUZK8TzA1NK2YdjRt1AwrgXRYGRyctrz/mt4WyO0SMOVQQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -3841,19 +3846,19 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
"tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
"tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
"tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="],
"tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="],
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
"text-decoder": ["text-decoder@1.2.4", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-mzlffA3tBNhziEHPK5L5InZg1d/ElNIpJhnhbDRNUtem/edZcJ5zg5FgwKKKOyklxk+6Jt+TrSu83musmvrDlg=="],
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
@@ -3961,7 +3966,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="],
"undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
@@ -4127,7 +4132,7 @@
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@@ -4277,8 +4282,6 @@
"@azure/core-http/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@azure/core-http/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"@azure/core-xml/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
@@ -4299,7 +4302,7 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.21.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -4501,7 +4504,9 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
@@ -4509,6 +4514,8 @@
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"ai-gateway-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -4529,6 +4536,8 @@
"aws-sdk/events": ["events@1.1.1", "", {}, "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw=="],
"aws-sdk/xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
@@ -4539,6 +4548,10 @@
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"body-parser/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -4573,6 +4586,8 @@
"express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"express/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -4581,7 +4596,7 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"glob/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
"glob/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -4619,8 +4634,6 @@
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"mssql/tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -4649,10 +4662,10 @@
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
@@ -4709,6 +4722,8 @@
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -4725,6 +4740,8 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -4745,6 +4762,8 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
@@ -5005,8 +5024,6 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
@@ -5015,7 +5032,7 @@
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -5035,6 +5052,8 @@
"astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"aws-sdk/xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
"babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
@@ -5069,8 +5088,6 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],

1
github/sst-env.d.ts vendored
View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../sst-env.d.ts" />

View File

@@ -214,9 +214,9 @@ new sst.cloudflare.x.SolidStart("Console", {
},
transform: {
server: {
placement: { region: "aws:us-east-1" },
transform: {
worker: {
placement: { mode: "smart" },
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
"x86_64-linux": "sha256-5sXHoHbRdXbqM/zRJZiXt26sm/yyyZN/4OOHUtdofhk=",
"aarch64-linux": "sha256-JCMm5X7e27BBV4wyaknCMM4CBt4Lr72SSvaGxEeNsJE=",
"aarch64-darwin": "sha256-DBQJURlTPqFt0OYUHSvZZ4H0NUf020aic4zNX5CXzDc=",
"x86_64-darwin": "sha256-t2luVxqCcRSgq/WNWkm4ZpKXO22n2RnAWP6msoTOr+A="
}
}

View File

@@ -69,10 +69,11 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.17.23",
"sst": "3.18.10",
"turbo": "2.5.6"
},
"dependencies": {

View File

@@ -1,35 +1,36 @@
import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout"
import { MetaProvider } from "@solidjs/meta"
import { Navigate, Route, Router } from "@solidjs/router"
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
@@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
}
}
@@ -107,30 +112,6 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
)
}
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
@@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.url} keyed>
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform()
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
const defaultServerUrl = resolveDefaultServerUrl({
defaultUrl: props.defaultUrl,
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) {
return (
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>

View File

@@ -1,19 +1,18 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps {
@@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) {
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStatus(result.healthy)
}
@@ -171,14 +170,13 @@ export function DialogSelectServer() {
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>,
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
adding: false,
@@ -214,24 +212,25 @@ export function DialogSelectServer() {
})
}
const replaceServer = (original: string, next: string) => {
const active = server.url
const nextActive = active === original ? next : active
const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.key
const newConn = server.add(next)
if (!newConn) return
server.add(next)
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(original)
server.remove(ServerConnection.key(original))
}
const items = createMemo(() => {
const current = server.url
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const sortedItems = createMemo(() => {
const list = items()
@@ -246,17 +245,17 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerHealth> = {}
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -269,15 +268,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval))
})
async function select(value: string, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist) {
server.add(value)
server.add(conn.http.url)
navigate("/")
return
}
server.setActive(value)
server.setActive(ServerConnection.key(conn))
navigate("/")
}
@@ -311,7 +310,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth(normalized, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
@@ -320,25 +319,25 @@ export function DialogSelectServer() {
}
resetAdd()
await select(normalized, true)
await select({ type: "http", http: { url: normalized } }, true)
}
async function handleEdit(original: string, value: string) {
if (store.editServer.busy) return
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
if (normalized === original) {
if (normalized === original.http.url) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth(normalized, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
@@ -366,7 +365,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url)
}
const handleEditKey = (event: KeyboardEvent, original: string) => {
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
@@ -378,7 +377,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value)
}
async function handleRemove(url: string) {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
@@ -390,11 +389,14 @@ export function DialogSelectServer() {
<div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}>
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) select(x)
}}
@@ -428,7 +430,7 @@ export function DialogSelectServer() {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
when={store.editServer.id !== i.http.url}
fallback={
<EditRow
value={store.editServer.value}
@@ -443,12 +445,12 @@ export function DialogSelectServer() {
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
conn={i}
status={store.status[ServerConnection.key(i)]}
dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -456,59 +458,63 @@ export function DialogSelectServer() {
}
/>
</Show>
<Show when={store.editServer.id !== i}>
<Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item onSelect={() => setDefault(i)}>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i.http.url,
value: i.http.url,
error: "",
status: store.status[ServerConnection.key(i)]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</Show>
</div>

View File

@@ -550,8 +550,15 @@ export default function FileTree(props: {
</Match>
<Match when={!node.ignored}>
<span class="filetree-iconpair size-4">
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
/>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
mono
/>
</span>
</Match>
</Switch>

View File

@@ -1375,8 +1375,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": mode === "shell" && store.mode === "shell",
"text-icon-interactive-base": mode === "normal" && store.mode === "normal",
"text-icon-strong-base": store.mode === mode,
"text-icon-weak": store.mode !== mode,
}}
/>

View File

@@ -12,24 +12,27 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => ({
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
})
}
}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@@ -88,11 +91,17 @@ beforeAll(async () => {
}))
mock.module("@/context/sdk", () => ({
useSDK: () => ({
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
}),
useSDK: () => {
const sdk = {
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
}
return sdk
},
}))
mock.module("@/context/sync", () => ({

View File

@@ -1,21 +1,20 @@
import { Accessor } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocal } from "@/context/local"
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
type PendingPrompt = {
abort: AbortController
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal()
const prompt = usePrompt()
const layout = useLayout()
@@ -175,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
client = sdk.createClient({
directory: sessionDirectory,
throwOnError: true,
})
@@ -372,7 +368,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})

View File

@@ -1,10 +1,19 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { serverDisplayName } from "@/context/server"
import {
createEffect,
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
url: string
conn: ServerConnection.Any
status?: ServerHealth
class?: string
nameClass?: string
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url))
const name = createMemo(() => serverDisplayName(props.conn))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
props.url
props.conn.http.url
props.status?.version
queueMicrotask(check)
})

View File

@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
@@ -91,11 +85,45 @@ function RawMessage(props: {
)
}
export function SessionContextTab(props: SessionContextTabProps) {
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const params = useParams()
const sync = useSync()
const layout = useLayout()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
},
emptyMessages,
{ 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()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
@@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const counts = createMemo(() => {
const all = props.messages()
const all = messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const systemPrompt = createMemo(() => {
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: props.messages(),
messages: messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}
const stats = [
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
@@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const el = scroll
if (!el) return
const s = props.view()?.scroll("context")
const s = view().scroll("context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
pending = undefined
if (!next) return
props.view().setScroll("context", next)
view().setScroll("context", next)
})
}
createEffect(
on(
() => props.messages().length,
() => messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple>
<For each={props.messages()}>
<For each={messages()}>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}

View File

@@ -431,7 +431,7 @@ export const SettingsGeneral: Component = () => {
<SoundsSection />
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
@@ -457,7 +457,7 @@ export const SettingsGeneral: Component = () => {
</div>
)
}}
</Show>
</Show>*/}
<UpdatesSection />

View File

@@ -1,21 +1,21 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
}
const listServersByHealth = (
list: string[],
active: string | undefined,
status: Record<string, ServerHealth | undefined>,
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(status[a]) - rank(status[b])
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
return status
}
const useDefaultServerUrl = (
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [url, setUrl] = createSignal<string | undefined>()
@@ -117,7 +117,14 @@ const useDefaultServerUrl = (
})
})
return { url, refresh: () => setTick((value) => value + 1) }
return {
key: () => {
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
}
}
const useMcpToggle = (input: {
@@ -163,16 +170,16 @@ export function StatusPopover() {
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.url
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -251,8 +258,9 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(url) => {
const isBlocked = () => health[url]?.healthy === false
{(s) => {
const key = ServerConnection.key(s)
const isBlocked = () => health[key]?.healthy === false
return (
<button
type="button"
@@ -264,19 +272,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
server.setActive(url)
server.setActive(key)
navigate("/")
}}
>
<ServerRow
url={url}
status={health[url]}
conn={s}
status={health[key]}
dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={url === defaultServer.url()}>
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -284,7 +292,7 @@ export function StatusPopover() {
}
>
<div class="flex-1" />
<Show when={url === server.url}>
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>

View File

@@ -1,14 +1,15 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings"
import { parseKeybind, matchKeybind } from "@/context/command"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: {
input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
input.container.addEventListener("click", input.handleLinkClick, {
capture: true,
})
input.cleanups.push(() =>
input.container.removeEventListener("click", input.handleLinkClick, {
capture: true,
}),
)
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
let ws: WebSocket | undefined
@@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer
t.open(container)
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
useTerminalUiBindings({
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
focusTerminal()
@@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket

View File

@@ -1,8 +1,9 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -17,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => {
if (!platform.fetch) return
if (!platform.fetch || !server.current) return
try {
const url = new URL(server.url)
const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
@@ -38,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
})()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
const currentServer = server.current
if (!currentServer) throw new Error("No server available")
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
headers: eventFetch ? undefined : auth,
server: currentServer.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
@@ -133,7 +126,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.url,
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
@@ -166,7 +159,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.url,
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
@@ -200,12 +193,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
flush()
})
const sdk = createOpencodeClient({
baseUrl: server.url,
const sdk = createSdkForServer({
server: server.current.http,
fetch: platform.fetch,
throwOnError: true,
})
return { url: server.url, client: sdk, event: emitter }
return {
url: currentServer.http.url,
client: sdk,
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
},
})

View File

@@ -30,7 +30,6 @@ describe("pickDirectoriesToEvict", () => {
describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
@@ -39,20 +38,15 @@ describe("loadRootSessionsWithFallback", () => {
calls.push(query)
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
expect(fallback).toBe(0)
})
test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
@@ -62,9 +56,6 @@ describe("loadRootSessionsWithFallback", () => {
if (query.limit) throw new Error("unsupported")
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
@@ -73,7 +64,6 @@ describe("loadRootSessionsWithFallback", () => {
{ directory: "dir", roots: true, limit: 25 },
{ directory: "dir", roots: true },
])
expect(fallback).toBe(1)
})
})

View File

@@ -1,41 +1,41 @@
import {
type Config,
type Path,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type Todo,
createOpencodeClient,
import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
createEffect,
untrack,
getOwner,
useContext,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
Match,
untrack,
useContext,
} from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import { createRefreshQueue } from "./global-sync/queue"
import { createChildStoreManager } from "./global-sync/child-store"
import { trimSessions } from "./global-sync/session-trim"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
type GlobalStore = {
ready: boolean
@@ -57,14 +57,6 @@ function errorMessage(error: unknown) {
return "Unknown error"
}
function setDevStats(value: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}) {
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -72,12 +64,7 @@ function createGlobalSync() {
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
const stats = {
evictions: 0,
loadSessionsFallback: 0,
}
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
@@ -112,15 +99,6 @@ function createGlobalSync() {
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
})
}
const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({
@@ -131,11 +109,6 @@ function createGlobalSync() {
const children = createChildStoreManager({
owner,
markStats: updateStats,
incrementEvictions: () => {
stats.evictions += 1
updateStats(Object.keys(children.children).length)
},
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
@@ -151,9 +124,7 @@ function createGlobalSync() {
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
@@ -193,7 +164,10 @@ function createGlobalSync() {
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
const next = trimSessions(store.session, {
limit: store.limit,
permission: store.permission,
})
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
}
@@ -206,10 +180,6 @@ function createGlobalSync() {
directory,
limit,
list: (query) => globalSDK.client.session.list(query),
onFallback: () => {
stats.loadSessionsFallback += 1
updateStats(Object.keys(children.children).length)
},
})
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -218,10 +188,17 @@ function createGlobalSync() {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
permission: store.permission,
})
setStore(
"sessionTotal",
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit })
@@ -331,7 +308,9 @@ function createGlobalSync() {
await bootstrapGlobal({
globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore,
})

View File

@@ -1,21 +1,21 @@
import {
type Config,
type Path,
type PermissionRequest,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
type Todo,
createOpencodeClient,
import type {
Config,
OpencodeClient,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types"
import { cmp, normalizeProviderList } from "./utils"
type GlobalStore = {
ready: boolean
@@ -31,7 +31,7 @@ type GlobalStore = {
}
export async function bootstrapGlobal(input: {
globalSDK: ReturnType<typeof createOpencodeClient>
globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
@@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
export async function bootstrapDirectory(input: {
directory: string
sdk: ReturnType<typeof createOpencodeClient>
sdk: OpencodeClient
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache

View File

@@ -17,8 +17,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},

View File

@@ -17,8 +17,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
export function createChildStoreManager(input: {
owner: Owner
markStats: (activeDirectoryStores: number) => void
incrementEvictions: () => void
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
@@ -102,7 +100,6 @@ export function createChildStoreManager(input: {
}
delete children[directory]
input.onDispose(directory)
input.markStats(Object.keys(children).length)
return true
}
@@ -120,7 +117,6 @@ export function createChildStoreManager(input: {
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue
input.incrementEvictions()
}
}
@@ -200,7 +196,6 @@ export function createChildStoreManager(input: {
})
runWithOwner(input.owner, init)
input.markStats(Object.keys(children).length)
}
mark(directory)
const childStore = children[directory]

View File

@@ -9,7 +9,6 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
limited: true,
} as const
} catch {
input.onFallback()
const result = await input.list({ directory: input.directory, roots: true })
return {
data: result.data,

View File

@@ -119,7 +119,6 @@ export type RootLoadArgs = {
directory: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
onFallback: () => void
}
export type RootLoadResult = {

View File

@@ -1,9 +1,8 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
@@ -12,14 +11,11 @@ type SDKEventMap = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: Accessor<string> }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory)
const client = createMemo(() =>
createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
globalSDK.createClient({
directory: directory(),
throwOnError: true,
}),
@@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
},
})

View File

@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
@@ -15,92 +15,118 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "")
}
export function serverDisplayName(url: string) {
if (!url) return ""
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
export function serverDisplayName(conn?: ServerConnection.Any) {
if (!conn) return ""
if (conn.displayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function projectsKey(url: string) {
if (!url) return ""
const host = url.replace(/^https?:\/\//, "").split(":")[0]
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar") return "local"
const host = key.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
return url
return key
}
export namespace ServerConnection {
type Base = { displayName?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
}
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
const allServers = createMemo(
(): Array<ServerConnection.Any> => [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
],
)
const [state, setState] = createStore({
active: "",
active: props.defaultServer,
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setState("active", url)
})
}
function startHealthPolling(url: string) {
function startHealthPolling(conn: ServerConnection.Any) {
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(url)
void check(conn)
.then((next) => {
if (!alive) return
setState("healthy", next)
@@ -118,59 +144,73 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
}
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
setState("active", url)
function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input)
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
updateServerList(url)
return batch(() => {
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
}
function remove(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
updateServerList(url, true)
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => x !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
}
})
}
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
})
const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => {
const url = state.active
if (!url) return
const current_ = current()
if (!current_) return
setState("healthy", undefined)
onCleanup(startHealthPolling(url))
onCleanup(startHealthPolling(current_))
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local")
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" && c.variant === "base"
})
return {
ready: isReady,
healthy,
isLocal,
get url() {
get key() {
return state.active
},
get name() {
return serverDisplayName(state.active)
return serverDisplayName(current())
},
get list() {
return store.list
return allServers()
},
get current() {
return current()
},
setActive,
add,

View File

@@ -1,11 +1,14 @@
// @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -107,12 +110,22 @@ const platform: Platform = {
setDefaultServerUrl: writeDefaultServerUrl,
}
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface />
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders>
</PlatformProvider>
),

View File

@@ -1,4 +1,5 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -51,7 +51,6 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
@@ -826,14 +825,6 @@ export default function Layout(props: ParentProps) {
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
}
@@ -869,15 +860,6 @@ export default function Layout(props: ParentProps) {
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return

View File

@@ -1,57 +1,30 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore, produce } from "solid-js/store"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Dialog } from "@opencode-ai/ui/dialog"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Select } from "@opencode-ai/ui/select"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { SessionHeader, NewSessionView } from "@/components/session"
import { same } from "@/utils/same"
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers"
import { createOpenReviewFile } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
SessionReviewTab,
StickyAddButton,
type DiffStyle,
type SessionReviewTabProps,
} from "@/pages/session/review-tab"
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
@@ -59,98 +32,34 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > HANDOFF_MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
}
export default function Page() {
const layout = useLayout()
const local = useLocal()
const file = useFile()
const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const language = useLanguage()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const permission = usePermission()
const permRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const questionRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const blocked = createMemo(() => !!permRequest() || !!questionRequest())
const [ui, setUi] = createStore({
responding: false,
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
autoCreated: false,
scroll: {
overflow: false,
bottom: true,
},
})
createEffect(
on(
() => permRequest()?.id,
() => setUi("responding", false),
{ defer: true },
),
)
const blocked = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
})
const decide = (response: "once" | "always" | "reject") => {
const perm = permRequest()
if (!perm) return
if (ui.responding) return
setUi("responding", true)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setUi("responding", false))
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
@@ -193,46 +102,6 @@ export default function Page() {
),
)
if (import.meta.env.DEV) {
createEffect(
on(
() => [params.dir, params.id] as const,
([dir, id], prev) => {
if (!id) return
navParams({ dir, from: prev?.[1], to: id })
},
),
)
createEffect(() => {
const id = params.id
if (!id) return
if (!prompt.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!terminal.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!file.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (sync.data.message[id] === undefined) return
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
})
}
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
@@ -265,16 +134,6 @@ export default function Page() {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (!path) return
file.load(path)
openReviewPanel()
}
createEffect(() => {
const active = tabs().active()
if (!active) return
@@ -323,206 +182,6 @@ export default function Page() {
return sync.session.history.loading(id)
})
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: info()?.title ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const sessionID = params.id
if (!sessionID) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (info()?.title ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function deleteSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: title() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -555,8 +214,6 @@ export default function Page() {
)
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
@@ -615,33 +272,6 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const emptyDiffFiles: string[] = []
const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same })
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
@@ -649,7 +279,6 @@ export default function Page() {
return sync.data.session_diff[id] !== undefined
})
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let dockHeight = 0
@@ -679,43 +308,6 @@ export default function Page() {
void sync.session.todo(id)
})
createEffect(() => {
if (!view().terminal.opened()) {
setUi("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
terminal.new()
setUi("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (view().terminal.opened()) {
view().terminal.toggle()
}
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !view().terminal.opened()) return
// Immediately remove focus
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -728,20 +320,12 @@ export default function Page() {
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect(
on(
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setUi("autoCreated", false)
},
{ defer: true },
),
@@ -768,11 +352,6 @@ export default function Page() {
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -827,53 +406,6 @@ export default function Page() {
}
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeTerminalDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
}
const handleTerminalDragEnd = () => {
setStore("activeTerminalDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
@@ -911,29 +443,8 @@ export default function Page() {
const focusInput = () => inputRef?.focus()
useSessionCommands({
command,
dialog,
file,
language,
local,
permission,
prompt,
sdk,
sync,
terminal,
layout,
params,
navigate,
tabs,
view,
info,
status,
userMessages,
visibleUserMessages,
showAllFiles,
navigateMessageByOffset,
setActiveMessage,
addSelectionToContext,
focusInput,
})
@@ -1071,11 +582,6 @@ export default function Page() {
),
)
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
setFileTreeTab(value)
}
const reviewDiffId = (path: string) => {
const sum = checksum(path)
if (!sum) return
@@ -1171,12 +677,6 @@ export default function Page() {
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
@@ -1485,58 +985,6 @@ export default function Page() {
document.addEventListener("keydown", handleKeyDown)
})
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
createEffect(() => {
if (!terminal.ready()) return
language.locale()
touch(
handoff.terminal,
params.dir!,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
@@ -1555,7 +1003,6 @@ export default function Page() {
reviewCount={reviewCount()}
onSession={() => setStore("mobileTab", "session")}
onChanges={() => setStore("mobileTab", "changes")}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
/>
{/* Session panel */}
@@ -1595,27 +1042,7 @@ export default function Page() {
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
showHeader={!!(info()?.title || info()?.parentID)}
centered={centered()}
title={info()?.title}
parentID={info()?.parentID}
openTitleEditor={openTitleEditor}
closeTitleEditor={closeTitleEditor}
saveTitleEditor={saveTitleEditor}
titleRef={(el) => {
titleRef = el
}}
titleState={title}
onTitleDraft={(value) => setTitle("draft", value)}
onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
onTitlePendingRename={(value) => setTitle("pendingRename", value)}
onNavigateParent={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
sessionID={params.id!}
onArchiveSession={(sessionID) => void archiveSession(sessionID)}
onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
@@ -1637,11 +1064,6 @@ export default function Page() {
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
onFirstTurnMount={() => {
const id = params.id
if (!id) return
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
/>
</Show>
@@ -1670,15 +1092,6 @@ export default function Page() {
<SessionPromptDock
centered={centered()}
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
responding={ui.responding}
onDecide={decide}
inputRef={(el) => {
inputRef = el
}}
@@ -1688,7 +1101,9 @@ export default function Page() {
comments.clear()
resumeScroll()
}}
setPromptDockRef={(el) => (promptDock = el)}
setPromptDockRef={(el) => {
promptDock = el
}}
/>
<Show when={desktopReviewOpen()}>
@@ -1702,64 +1117,10 @@ export default function Page() {
</Show>
</div>
<SessionSidePanel
open={desktopSidePanelOpen()}
reviewOpen={desktopReviewOpen()}
language={language}
layout={layout}
command={command}
dialog={dialog}
file={file}
comments={comments}
hasReview={hasReview()}
reviewCount={reviewCount()}
reviewTab={reviewTab()}
contextOpen={contextOpen}
openedTabs={openedTabs}
activeTab={activeTab}
activeFileTab={activeFileTab}
tabs={tabs}
openTab={openTab}
showAllFiles={showAllFiles}
reviewPanel={reviewPanel}
vm={{
messages,
visibleUserMessages,
view,
info,
}}
handoffFiles={() => handoff.session.get(sessionKey())?.files}
codeComponent={codeComponent}
addCommentToContext={addCommentToContext}
activeDraggable={() => store.activeDraggable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
fileTreeTab={fileTreeTab}
setFileTreeTabValue={setFileTreeTabValue}
diffsReady={diffsReady()}
diffFiles={diffFiles()}
kinds={kinds()}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
/>
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
</div>
<TerminalPanel
open={isDesktop() && view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}
terminal={terminal}
language={language}
command={command}
handoff={() => handoff.terminal.get(params.dir!) ?? []}
activeTerminalDraggable={() => store.activeTerminalDraggable}
handleTerminalDragStart={handleTerminalDragStart}
handleTerminalDragOver={handleTerminalDragOver}
handleTerminalDragEnd={handleTerminalDragEnd}
onCloseTab={() => setUi("autoCreated", false)}
/>
<TerminalPanel />
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
@@ -8,9 +10,11 @@ import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useLayout } from "@/context/layout"
import { useFile, type SelectedLineRange } from "@/context/file"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff"
const formatCommentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
@@ -19,34 +23,29 @@ const formatCommentLabel = (range: SelectedLineRange) => {
return `lines ${start}-${end}`
}
export function FileTabContent(props: {
tab: string
activeTab: () => string
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments>
language: ReturnType<typeof useLanguage>
codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
}) {
export function FileTabContent(props: { tab: string }) {
const params = useParams()
const layout = useLayout()
const file = useFile()
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const codeComponent = useCodeComponent()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
const path = createMemo(() => props.file.pathFromTab(props.tab))
const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => {
const p = path()
if (!p) return
return props.file.get(p)
return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
@@ -82,7 +81,7 @@ export function FileTabContent(props: {
svgToast.shown = true
showToast({
variant: "error",
title: props.language.t("toast.file.loadFailed.title"),
title: language.t("toast.file.loadFailed.title"),
})
})
const svgPreviewUrl = createMemo(() => {
@@ -100,16 +99,57 @@ export function FileTabContent(props: {
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
if (props.file.ready()) return props.file.selectedLines(p) ?? null
return props.handoffFiles()?.[p] ?? null
if (file.ready()) return file.selectedLines(p) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? null
})
const selectionPreview = (source: string, selection: FileSelection) => {
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = source.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return props.comments.list(p)
return comments.list(p)
})
const commentLayout = createMemo(() => {
@@ -228,19 +268,19 @@ export function FileTabContent(props: {
})
createEffect(() => {
const focus = props.comments.focus()
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (props.activeTab() !== props.tab) return
if (tabs().active() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
setNote("openedComment", target.id)
setNote("commenting", null)
props.file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => props.comments.clearFocus())
file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => comments.clearFocus())
})
const getCodeScroll = () => {
@@ -269,7 +309,7 @@ export function FileTabContent(props: {
pending = undefined
if (!out) return
props.view().setScroll(props.tab, out)
view().setScroll(props.tab, out)
})
}
@@ -305,7 +345,7 @@ export function FileTabContent(props: {
const el = scroll
if (!el) return
const s = props.view()?.scroll(props.tab)
const s = view().scroll(props.tab)
if (!s) return
syncCodeScroll()
@@ -343,7 +383,7 @@ export function FileTabContent(props: {
createEffect(
on(
() => props.file.ready(),
() => file.ready(),
(ready) => {
if (!ready) return
requestAnimationFrame(restoreScroll)
@@ -354,7 +394,7 @@ export function FileTabContent(props: {
createEffect(
on(
() => props.tabs().active() === props.tab,
() => tabs().active() === props.tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
@@ -381,7 +421,7 @@ export function FileTabContent(props: {
class={`relative overflow-hidden ${wrapperClass}`}
>
<Dynamic
component={props.codeComponent}
component={codeComponent}
file={{
name: path() ?? "",
contents: source,
@@ -397,7 +437,7 @@ export function FileTabContent(props: {
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
props.file.setSelectedLines(p, range)
file.setSelectedLines(p, range)
if (!range) setNote("commenting", null)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
@@ -423,14 +463,14 @@ export function FileTabContent(props: {
onMouseEnter={() => {
const p = path()
if (!p) return
props.file.setSelectedLines(p, comment.selection)
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setNote("commenting", null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
props.file.setSelectedLines(p, comment.selection)
file.setSelectedLines(p, comment.selection)
}}
/>
)}
@@ -447,12 +487,7 @@ export function FileTabContent(props: {
onSubmit={(value) => {
const p = path()
if (!p) return
props.addCommentToContext({
file: p,
selection: range(),
comment: value,
origin: "file",
})
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setNote("commenting", null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
@@ -509,13 +544,13 @@ export function FileTabContent(props: {
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>

View File

@@ -0,0 +1,36 @@
import type { SelectedLineRange } from "@/context/file"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const MAX = 40
const store = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = store.session.get(key) ?? { prompt: "", files: {} }
touch(store.session, key, { ...prev, ...patch })
}
export const getSessionHandoff = (key: string) => store.session.get(key)
export const setTerminalHandoff = (key: string, value: string[]) => {
touch(store.terminal, key, value)
}
export const getTerminalHandoff = (key: string) => store.terminal.get(key)

View File

@@ -1,13 +1,21 @@
import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
@@ -53,29 +61,7 @@ export function MessageTimeline(props: {
isDesktop: boolean
onScrollSpyScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
showHeader: boolean
centered: boolean
title?: string
parentID?: string
openTitleEditor: () => void
closeTitleEditor: () => void
saveTitleEditor: () => void | Promise<void>
titleRef: (el: HTMLInputElement) => void
titleState: {
draft: string
editing: boolean
saving: boolean
menuOpen: boolean
pendingRename: boolean
}
onTitleDraft: (value: string) => void
onTitleMenuOpen: (open: boolean) => void
onTitlePendingRename: (value: boolean) => void
onNavigateParent: () => void
sessionID: string
onArchiveSession: (sessionID: string) => void
onDeleteSession: (sessionID: string) => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
setContentRef: (el: HTMLDivElement) => void
turnStart: number
onRenderEarlier: () => void
@@ -86,11 +72,234 @@ export function MessageTimeline(props: {
anchor: (id: string) => string
onRegisterMessage: (el: HTMLDivElement, id: string) => void
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
}) {
let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show
when={!props.mobileChanges}
@@ -157,9 +366,9 @@ export function MessageTimeline(props: {
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
style={{ "--session-title-height": showHeader() ? "40px" : "0px" }}
>
<Show when={props.showHeader}>
<Show when={showHeader()}>
<div
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
@@ -171,92 +380,96 @@ export function MessageTimeline(props: {
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={props.parentID}>
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={props.onNavigateParent}
aria-label={props.t("common.goBack")}
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={props.title || props.titleState.editing}>
<Show when={titleValue() || title.editing}>
<Show
when={props.titleState.editing}
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={props.openTitleEditor}
onDblClick={openTitleEditor}
>
{props.title}
{titleValue()}
</h1>
}
>
<InlineInput
ref={props.titleRef}
value={props.titleState.draft}
disabled={props.titleState.saving}
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void props.saveTitleEditor()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
props.closeTitleEditor()
closeTitleEditor()
}
}}
onBlur={props.closeTitleEditor}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={props.sessionID}>
<Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={props.titleState.menuOpen}
onOpenChange={props.onTitleMenuOpen}
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={props.t("common.moreOptions")}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!props.titleState.pendingRename) return
if (!title.pendingRename) return
event.preventDefault()
props.onTitlePendingRename(false)
props.openTitleEditor()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
props.onTitlePendingRename(true)
props.onTitleMenuOpen(false)
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
<DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
<DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -282,7 +495,7 @@ export function MessageTimeline(props: {
<Show when={props.turnStart > 0}>
<div class="w-full flex justify-center">
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
{props.t("session.messages.renderEarlier")}
{language.t("session.messages.renderEarlier")}
</Button>
</div>
</Show>
@@ -296,43 +509,37 @@ export function MessageTimeline(props: {
onClick={props.onLoadEarlier}
>
{props.historyLoading
? props.t("session.messages.loadingEarlier")
: props.t("session.messages.loadEarlier")}
? language.t("session.messages.loadingEarlier")
: language.t("session.messages.loadEarlier")}
</Button>
</div>
</Show>
<For each={props.renderedUserMessages}>
{(message) => {
if (import.meta.env.DEV && props.onFirstTurnMount) {
onMount(() => props.onFirstTurnMount?.())
}
return (
<div
id={props.anchor(message.id)}
data-message-id={message.id}
ref={(el) => {
props.onRegisterMessage(el, message.id)
onCleanup(() => props.onUnregisterMessage(message.id))
{(message) => (
<div
id={props.anchor(message.id)}
data-message-id={message.id}
ref={(el) => {
props.onRegisterMessage(el, message.id)
onCleanup(() => props.onUnregisterMessage(message.id))
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<SessionTurn
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}}
/>
</div>
)
}}
/>
</div>
)}
</For>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useLanguage } from "@/context/language"
export function SessionMobileTabs(props: {
open: boolean
@@ -8,8 +9,9 @@ export function SessionMobileTabs(props: {
reviewCount: number
onSession: () => void
onChanges: () => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
}) {
const language = useLanguage()
return (
<Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto">
@@ -20,7 +22,7 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }}
onClick={props.onSession}
>
{props.t("session.tab.session")}
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
@@ -29,8 +31,8 @@ export function SessionMobileTabs(props: {
onClick={props.onChanges}
>
{props.hasReview
? props.t("session.review.filesChanged", { count: props.reviewCount })
: props.t("session.review.change.other")}
? language.t("session.review.filesChanged", { count: props.reviewCount })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>

View File

@@ -1,35 +1,105 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
export function SessionPromptDock(props: {
centered: boolean
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const done = createMemo(
() =>
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const prompt = usePrompt()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const questionRequest = createMemo((): QuestionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [responding, setResponding] = createSignal(false)
createEffect(
on(
() => permissionRequest()?.id,
() => setResponding(false),
{ defer: true },
),
)
const [dock, setDock] = createSignal(props.todos.length > 0)
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (responding()) return
setResponding(true)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setResponding(false))
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const [dock, setDock] = createSignal(todos().length > 0)
const [closing, setClosing] = createSignal(false)
const [opening, setOpening] = createSignal(false)
let timer: number | undefined
@@ -46,7 +116,7 @@ export function SessionPromptDock(props: {
createEffect(
on(
() => [props.todos.length, done()] as const,
() => [todos().length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
@@ -113,7 +183,7 @@ export function SessionPromptDock(props: {
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.questionRequest()} keyed>
<Show when={questionRequest()} keyed>
{(req) => {
return (
<div>
@@ -123,11 +193,11 @@ export function SessionPromptDock(props: {
}}
</Show>
<Show when={props.permissionRequest()} keyed>
<Show when={permissionRequest()} keyed>
{(perm) => {
const toolDescription = () => {
const key = `settings.permissions.tool.${perm.permission}.description`
const value = props.t(key)
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}
@@ -141,36 +211,26 @@ export function SessionPromptDock(props: {
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button
variant="ghost"
size="normal"
onClick={() => props.onDecide("reject")}
disabled={props.responding}
>
{props.t("ui.permission.deny")}
<Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
onClick={() => decide("always")}
disabled={responding()}
>
{props.t("ui.permission.allowAlways")}
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="normal"
onClick={() => props.onDecide("once")}
disabled={props.responding}
>
{props.t("ui.permission.allowOnce")}
<Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
@@ -199,12 +259,12 @@ export function SessionPromptDock(props: {
}}
</Show>
<Show when={!props.blocked}>
<Show when={!blocked()}>
<Show
when={props.promptReady}
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{props.handoffPrompt || props.t("prompt.loading")}
{handoffPrompt() || language.t("prompt.loading")}
</div>
}
>
@@ -219,10 +279,10 @@ export function SessionPromptDock(props: {
}}
>
<SessionTodoDock
todos={props.todos}
title={props.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")}
todos={todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
</div>
</Show>

View File

@@ -1,156 +1,269 @@
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { StickyAddButton } from "@/pages/session/review-tab"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useComments } from "@/context/comments"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { useCommand } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
type SessionSidePanelViewModel = {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { getTabReorderIndex } from "@/pages/session/helpers"
import { StickyAddButton } from "@/pages/session/review-tab"
import { setSessionHandoff } from "@/pages/session/handoff"
export function SessionSidePanel(props: {
open: boolean
reviewOpen: boolean
language: ReturnType<typeof useLanguage>
layout: ReturnType<typeof useLayout>
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments>
hasReview: boolean
reviewCount: number
reviewTab: boolean
contextOpen: () => boolean
openedTabs: () => string[]
activeTab: () => string
activeFileTab: () => string | undefined
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
openTab: (value: string) => void
showAllFiles: () => void
reviewPanel: () => JSX.Element
vm: SessionSidePanelViewModel
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
activeDraggable: () => string | undefined
onDragStart: (event: unknown) => void
onDragEnd: () => void
onDragOver: (event: DragEvent) => void
fileTreeTab: () => "changes" | "all"
setFileTreeTabValue: (value: string) => void
diffsReady: boolean
diffFiles: string[]
kinds: Map<string, "add" | "del" | "mix">
activeDiff?: string
focusReviewDiff: (path: string) => void
}) {
const openedTabs = createMemo(() => props.openedTabs())
const params = useParams()
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (!path) return
file.load(path)
openReviewPanel()
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
.all()
.filter((tab) => tab !== "context" && tab !== "review"),
)
const activeTab = createMemo(() => {
const active = tabs().active()
if (active === "context") return "context"
if (active === "review" && reviewTab()) return "review"
if (active && file.pathFromTab(active)) return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (reviewTab() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
layout.fileTree.setTab("all")
}
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return (
<Show when={props.open}>
<Show when={open()}>
<aside
id="review-panel"
aria-label={props.language.t("session.panel.reviewAndFiles")}
aria-label={language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weak-base flex"
classList={{
"flex-1": props.reviewOpen,
"shrink-0": !props.reviewOpen,
"flex-1": reviewOpen(),
"shrink-0": !reviewOpen(),
}}
style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }}
style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
>
<Show when={props.reviewOpen}>
<Show when={reviewOpen()}>
<div class="flex-1 min-w-0 h-full">
<Show
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
when={layout.fileTree.opened() && fileTreeTab() === "changes"}
fallback={
<DragDropProvider
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
onDragOver={props.onDragOver}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={props.activeTab()} onChange={props.openTab}>
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={props.reviewTab}>
<Show when={reviewTab()}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5">
<div>{props.language.t("session.tab.review")}</div>
<Show when={props.hasReview}>
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{props.reviewCount}
{reviewCount()}
</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={props.contextOpen()}>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
<Tooltip value={language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.tabs().close("context")}
aria-label={props.language.t("common.closeTab")}
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.tabs().close("context")}
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{props.language.t("session.tab.context")}</div>
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
</For>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={props.language.t("command.file.open")}
keybind={props.command.keybind("file.open")}
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
@@ -158,72 +271,52 @@ export function SessionSidePanel(props: {
variant="ghost"
iconSize="large"
onClick={() =>
props.dialog.show(() => (
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
))
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}
aria-label={props.language.t("command.file.open")}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<Show when={props.reviewTab}>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "empty"}>
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{props.language.t("session.files.selectToOpen")}
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
<Show when={props.contextOpen()}>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "context"}>
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab
messages={props.vm.messages}
visibleUserMessages={props.vm.visibleUserMessages}
view={props.vm.view}
info={props.vm.info}
/>
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={props.activeFileTab()} keyed>
{(tab) => (
<FileTabContent
tab={tab}
activeTab={props.activeTab}
tabs={props.tabs}
view={props.vm.view}
handoffFiles={props.handoffFiles}
file={props.file}
comments={props.comments}
language={props.language}
codeComponent={props.codeComponent}
addCommentToContext={props.addCommentToContext}
/>
)}
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={props.activeDraggable()}>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = createMemo(() => props.file.pathFromTab(tab()))
const path = createMemo(() => file.pathFromTab(tab))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
@@ -240,50 +333,44 @@ export function SessionSidePanel(props: {
</div>
</Show>
<Show when={props.layout.fileTree.opened()}>
<div
id="file-tree-panel"
class="relative shrink-0 h-full"
style={{ width: `${props.layout.fileTree.width()}px` }}
>
<Show when={layout.fileTree.opened()}>
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weak-base": props.reviewOpen }}
classList={{ "border-l border-border-weak-base": reviewOpen() }}
>
<Tabs
variant="pill"
value={props.fileTreeTab()}
onChange={props.setFileTreeTabValue}
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount}{" "}
{props.language.t(
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
)}
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{props.language.t("session.files.all")}
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
<Switch>
<Match when={props.hasReview}>
<Match when={hasReview()}>
<Show
when={props.diffsReady}
when={diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{props.language.t("common.loading")}
{props.language.t("common.loading.ellipsis")}
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
allowed={props.diffFiles}
kinds={props.kinds}
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
@@ -292,7 +379,7 @@ export function SessionSidePanel(props: {
</Match>
<Match when={true}>
<div class="mt-8 text-center text-12-regular text-text-weak">
{props.language.t("session.review.noChanges")}
{language.t("session.review.noChanges")}
</div>
</Match>
</Switch>
@@ -300,9 +387,9 @@ export function SessionSidePanel(props: {
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
<FileTree
path=""
modified={props.diffFiles}
kinds={props.kinds}
onFileClick={(node) => props.openTab(props.file.tab(node.path))}
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Tabs.Content>
</Tabs>
@@ -310,12 +397,12 @@ export function SessionSidePanel(props: {
<ResizeHandle
direction="horizontal"
edge="start"
size={props.layout.fileTree.width()}
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={props.layout.fileTree.resize}
onCollapse={props.layout.fileTree.close}
onResize={layout.fileTree.resize}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>

View File

@@ -1,61 +1,161 @@
import { For, Show, createMemo } from "solid-js"
import { For, Show, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { SortableTerminalTab } from "@/components/session"
import { Terminal } from "@/components/terminal"
import { useTerminal } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel(props: {
open: boolean
height: number
resize: (value: number) => void
close: () => void
terminal: ReturnType<typeof useTerminal>
language: ReturnType<typeof useLanguage>
command: ReturnType<typeof useCommand>
handoff: () => string[]
activeTerminalDraggable: () => string | undefined
handleTerminalDragStart: (event: unknown) => void
handleTerminalDragOver: (event: DragEvent) => void
handleTerminalDragEnd: () => void
onCloseTab: () => void
}) {
const all = createMemo(() => props.terminal.all())
export function TerminalPanel() {
const params = useParams()
const layout = useLayout()
const terminal = useTerminal()
const language = useLanguage()
const command = useCommand()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
})
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
terminal.new()
setStore("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (opened()) view().terminal.toggle()
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !opened()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect(() => {
const dir = params.dir
if (!dir) return
if (!terminal.ready()) return
language.locale()
setTerminalHandoff(
dir,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
const handoff = createMemo(() => {
const dir = params.dir
if (!dir) return []
return getTerminalHandoff(dir) ?? []
})
const all = createMemo(() => terminal.all())
const ids = createMemo(() => all().map((pty) => pty.id))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
const handleTerminalDragEnd = () => {
setStore("activeDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
return (
<Show when={props.open}>
<Show when={open()}>
<div
id="terminal-panel"
role="region"
aria-label={props.language.t("terminal.title")}
aria-label={language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${props.height}px` }}
style={{ height: `${height()}px` }}
>
<ResizeHandle
direction="vertical"
size={props.height}
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={props.resize}
onCollapse={props.close}
onResize={layout.terminal.resize}
onCollapse={close}
/>
<Show
when={props.terminal.ready()}
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={props.handoff()}>
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
@@ -64,20 +164,18 @@ export function TerminalPanel(props: {
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{props.language.t("common.loading")}
{props.language.t("common.loading.ellipsis")}
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">
{props.language.t("terminal.loading")}
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
</div>
}
>
<DragDropProvider
onDragStart={props.handleTerminalDragStart}
onDragEnd={props.handleTerminalDragEnd}
onDragOver={props.handleTerminalDragOver}
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
@@ -85,36 +183,26 @@ export function TerminalPanel(props: {
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={props.terminal.active()}
onChange={(id) => props.terminal.open(id)}
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10">
<SortableProvider ids={ids()}>
<For each={all()}>
{(pty) => (
<SortableTerminalTab
terminal={pty}
onClose={() => {
props.close()
props.onCloseTab()
}}
/>
)}
</For>
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={props.language.t("command.terminal.new")}
keybind={props.command.keybind("terminal.new")}
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={props.terminal.new}
aria-label={props.language.t("command.terminal.new")}
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
@@ -127,15 +215,11 @@ export function TerminalPanel(props: {
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: props.terminal.active() === pty.id ? "block" : "none",
display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
<Terminal
pty={pty}
onCleanup={props.terminal.update}
onConnectError={() => props.terminal.clone(pty.id)}
/>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Show>
</div>
)}
@@ -143,25 +227,20 @@ export function TerminalPanel(props: {
</div>
</div>
<DragOverlay>
<Show when={props.activeTerminalDraggable()}>
{(draggedId) => {
return (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: props.language.t as (
key: string,
vars?: Record<string, string | number | boolean>,
) => string,
})}
</div>
)}
</Show>
)
}}
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
</Show>
</DragOverlay>
</DragDropProvider>

View File

@@ -22,29 +22,8 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = {
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
language: ReturnType<typeof useLanguage>
local: ReturnType<typeof useLocal>
permission: ReturnType<typeof usePermission>
prompt: ReturnType<typeof usePrompt>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
terminal: ReturnType<typeof useTerminal>
layout: ReturnType<typeof useLayout>
params: ReturnType<typeof useParams>
navigate: ReturnType<typeof useNavigate>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
}
@@ -55,45 +34,98 @@ const withCategory = (category: string) => {
})
}
export const useSessionCommands = (input: SessionCommandContext) => {
const sessionCommand = withCategory(input.language.t("command.category.session"))
const fileCommand = withCategory(input.language.t("command.category.file"))
const contextCommand = withCategory(input.language.t("command.category.context"))
const viewCommand = withCategory(input.language.t("command.category.view"))
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
const modelCommand = withCategory(input.language.t("command.category.model"))
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
const agentCommand = withCategory(input.language.t("command.category.agent"))
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
export const useSessionCommands = (actions: SessionCommandContext) => {
const command = useCommand()
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
}
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const navigateMessageByOffset = actions.navigateMessageByOffset
const setActiveMessage = actions.setActiveMessage
const focusInput = actions.focusInput
const sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
const terminalCommand = withCategory(language.t("command.category.terminal"))
const modelCommand = withCategory(language.t("command.category.model"))
const mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [
sessionCommand({
id: "session.new",
title: input.language.t("command.session.new"),
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => input.navigate(`/${input.params.dir}/session`),
onSelect: () => navigate(`/${params.dir}/session`),
}),
])
const fileCommands = createMemo(() => [
fileCommand({
id: "file.open",
title: input.language.t("command.file.open"),
description: input.language.t("palette.search.placeholder"),
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}),
fileCommand({
id: "tab.close",
title: input.language.t("command.tab.close"),
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !input.tabs().active(),
disabled: !tabs().active(),
onSelect: () => {
const active = input.tabs().active()
const active = tabs().active()
if (!active) return
input.tabs().close(active)
tabs().close(active)
},
}),
])
@@ -101,30 +133,30 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const contextCommands = createMemo(() => [
contextCommand({
id: "context.addSelection",
title: input.language.t("command.context.addSelection"),
description: input.language.t("command.context.addSelection.description"),
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
active: input.tabs().active(),
pathFromTab: input.file.pathFromTab,
selectedLines: input.file.selectedLines,
active: tabs().active(),
pathFromTab: file.pathFromTab,
selectedLines: file.selectedLines,
}),
onSelect: () => {
const active = input.tabs().active()
const active = tabs().active()
if (!active) return
const path = input.file.pathFromTab(active)
const path = file.pathFromTab(active)
if (!path) return
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: input.language.t("toast.context.noLineSelection.title"),
description: input.language.t("toast.context.noLineSelection.description"),
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
input.addSelectionToContext(path, selectionFromLines(range))
addSelectionToContext(path, selectionFromLines(range))
},
}),
])
@@ -132,37 +164,37 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const viewCommands = createMemo(() => [
viewCommand({
id: "terminal.toggle",
title: input.language.t("command.terminal.toggle"),
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => input.view().terminal.toggle(),
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: input.language.t("command.review.toggle"),
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => input.view().reviewPanel.toggle(),
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: input.language.t("command.fileTree.toggle"),
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => input.layout.fileTree.toggle(),
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: input.language.t("command.input.focus"),
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: () => input.focusInput(),
onSelect: () => focusInput(),
}),
terminalCommand({
id: "terminal.new",
title: input.language.t("command.terminal.new"),
description: input.language.t("command.terminal.new.description"),
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (input.terminal.all().length > 0) input.terminal.new()
input.view().terminal.open()
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
])
@@ -170,61 +202,61 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const messageCommands = createMemo(() => [
sessionCommand({
id: "message.previous",
title: input.language.t("command.message.previous"),
description: input.language.t("command.message.previous.description"),
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+arrowup",
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(-1),
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: input.language.t("command.message.next"),
description: input.language.t("command.message.next.description"),
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(1),
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
])
const agentCommands = createMemo(() => [
modelCommand({
id: "model.choose",
title: input.language.t("command.model.choose"),
description: input.language.t("command.model.choose.description"),
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
onSelect: () => dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
title: input.language.t("command.mcp.toggle"),
description: input.language.t("command.mcp.toggle.description"),
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
onSelect: () => dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: input.language.t("command.agent.cycle"),
description: input.language.t("command.agent.cycle.description"),
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => input.local.agent.move(1),
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: input.language.t("command.agent.cycle.reverse"),
description: input.language.t("command.agent.cycle.reverse.description"),
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => input.local.agent.move(-1),
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: input.language.t("command.model.variant.cycle"),
description: input.language.t("command.model.variant.cycle.description"),
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => {
input.local.model.variant.cycle()
local.model.variant.cycle()
},
}),
])
@@ -233,22 +265,22 @@ export const useSessionCommands = (input: SessionCommandContext) => {
permissionsCommand({
id: "permissions.autoaccept",
title:
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
? input.language.t("command.permissions.autoaccept.disable")
: input.language.t("command.permissions.autoaccept.enable"),
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: !input.params.id || !input.permission.permissionsEnabled(),
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.title")
: input.language.t("toast.permissions.autoaccept.off.title"),
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.description")
: input.language.t("toast.permissions.autoaccept.off.description"),
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
@@ -257,71 +289,71 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const sessionActionCommands = createMemo(() => [
sessionCommand({
id: "session.undo",
title: input.language.t("command.session.undo"),
description: input.language.t("command.session.undo.description"),
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
if (input.status()?.type !== "idle") {
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = input.info()?.revert?.messageID
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = input.sync.data.part[message.id]
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
input.prompt.set(restored)
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
input.setActiveMessage(priorMessage)
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: input.language.t("command.session.redo"),
description: input.language.t("command.session.redo.description"),
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !input.params.id || !input.info()?.revert?.messageID,
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
const revertMessageID = input.info()?.revert?.messageID
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await input.sdk.client.session.unrevert({ sessionID })
input.prompt.reset()
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
input.setActiveMessage(lastMsg)
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
input.setActiveMessage(priorMsg)
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: input.language.t("command.session.compact"),
description: input.language.t("command.session.compact.description"),
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = input.params.id
const sessionID = params.id
if (!sessionID) return
const model = input.local.model.current()
const model = local.model.current()
if (!model) {
showToast({
title: input.language.t("toast.model.none.title"),
description: input.language.t("toast.model.none.description"),
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await input.sdk.client.session.summarize({
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
@@ -330,29 +362,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
}),
sessionCommand({
id: "session.fork",
title: input.language.t("command.session.fork"),
description: input.language.t("command.session.fork.description"),
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => input.dialog.show(() => <DialogFork />),
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
}),
])
const shareCommands = createMemo(() => {
if (input.sync.data.config.share === "disabled") return []
if (sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !input.params.id,
disabled: !params.id,
onSelect: async () => {
if (!input.params.id) return
if (!params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@@ -382,7 +412,7 @@ export const useSessionCommands = (input: SessionCommandContext) => {
const ok = await write(url)
if (!ok) {
showToast({
title: input.language.t("toast.session.share.copyFailed.title"),
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
@@ -390,27 +420,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
showToast({
title: existing
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = input.info()?.share?.url
const existing = info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const url = await input.sdk.client.session
.share({ sessionID: input.params.id })
const url = await sdk.client.session
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
@@ -421,25 +451,25 @@ export const useSessionCommands = (input: SessionCommandContext) => {
}),
sessionCommand({
id: "session.unshare",
title: input.language.t("command.session.unshare"),
description: input.language.t("command.session.unshare.description"),
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !input.params.id || !input.info()?.share?.url,
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!input.params.id) return
await input.sdk.client.session
.unshare({ sessionID: input.params.id })
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: input.language.t("toast.session.unshare.success.title"),
description: input.language.t("toast.session.unshare.success.description"),
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: input.language.t("toast.session.unshare.failed.title"),
description: input.language.t("toast.session.unshare.failed.description"),
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
@@ -448,7 +478,7 @@ export const useSessionCommands = (input: SessionCommandContext) => {
]
})
input.command.register("session", () =>
command.register("session", () =>
[
sessionCommands(),
fileCommands(),

View File

@@ -1,6 +1,8 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* biome-ignore-all lint: auto-generated */
/// <reference types="vite/client" />
interface ImportMetaEnv {

View File

@@ -1,135 +0,0 @@
import { uuid } from "@/utils/uuid"
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uuid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uuid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}

View File

@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import { checkServerHealth } from "./server-health"
const server: ServerConnection.HttpBase = {
url: "http://localhost:4096",
}
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
if (init?.signal) return init.signal
if (input instanceof Request) return input.signal
@@ -15,7 +20,7 @@ describe("checkServerHealth", () => {
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch)
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
})
@@ -25,7 +30,7 @@ describe("checkServerHealth", () => {
throw new Error("network")
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch)
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: false })
})
@@ -51,7 +56,9 @@ describe("checkServerHealth", () => {
)
})) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => {
const result = await checkServerHealth(server, fetch, {
timeoutMs: 10,
}).finally(() => {
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
})
@@ -71,7 +78,9 @@ describe("checkServerHealth", () => {
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal })
await checkServerHealth(server, fetch, {
signal: abort.signal,
})
expect(signal).toBe(abort.signal)
})
@@ -87,7 +96,7 @@ describe("checkServerHealth", () => {
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, {
const result = await checkServerHealth(server, fetch, {
retryCount: 2,
retryDelayMs: 1,
})
@@ -103,7 +112,7 @@ describe("checkServerHealth", () => {
throw new TypeError("network")
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, {
const result = await checkServerHealth(server, fetch, {
retryCount: 2,
retryDelayMs: 1,
})

View File

@@ -1,4 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
export type ServerHealth = { healthy: boolean; version?: string }
@@ -17,7 +18,10 @@ function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
if (timeout) {
try {
return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined }
return {
signal: timeout.call(AbortSignal, timeoutMs),
clear: undefined as (() => void) | undefined,
}
} catch {}
}
const controller = new AbortController()
@@ -52,7 +56,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
}
export async function checkServerHealth(
url: string,
server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions,
): Promise<ServerHealth> {
@@ -67,8 +71,8 @@ export async function checkServerHealth(
.catch(() => ({ healthy: false }))
}
const attempt = (count: number): Promise<ServerHealth> =>
createOpencodeClient({
baseUrl: url,
createSdkForServer({
server,
fetch,
signal,
})

View File

@@ -0,0 +1,22 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
export function createSdkForServer({
server,
...config
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
server: ServerConnection.HttpBase
}) {
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
}
})()
return createOpencodeClient({
...config,
headers: { ...config.headers, ...auth },
baseUrl: server.url,
})
}

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />

View File

@@ -22,5 +22,6 @@
}
},
"include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"]
"exclude": ["dist", "ts-dist"],
"references": [{ "path": "../sdk/js" }]
}

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" />

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" />

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -9,7 +9,6 @@ use std::os::unix::process::ExitStatusExt;
use std::sync::Arc;
use std::{process::Stdio, time::Duration};
use tauri::{AppHandle, Manager, path::BaseDirectory};
use tauri_plugin_store::StoreExt;
use tauri_specta::Event;
use tokio::{
io::{AsyncBufRead, AsyncBufReadExt, BufReader},
@@ -20,7 +19,8 @@ use tokio::{
use tokio_stream::wrappers::ReceiverStream;
use tracing::Instrument;
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
use crate::server::get_wsl_config;
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
@@ -202,16 +202,8 @@ fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
fn is_wsl_enabled(app: &tauri::AppHandle) -> bool {
let Ok(store) = app.store(SETTINGS_STORE) else {
return false;
};
store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
}
fn shell_escape(input: &str) -> String {

View File

@@ -55,18 +55,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
#[tauri::command]
#[specta::specta]
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
pub fn get_wsl_config(_app: AppHandle) -> Result<WslConfig, String> {
// let store = app
// .store(SETTINGS_STORE)
// .map_err(|e| format!("Failed to open settings store: {}", e))?;
let enabled = store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
// let enabled = store
// .get(WSL_ENABLED_KEY)
// .as_ref()
// .and_then(|v| v.as_bool())
// .unwrap_or(false);
Ok(WslConfig { enabled })
Ok(WslConfig { enabled: false })
}
#[tauri::command]

View File

@@ -1,36 +1,37 @@
// @refresh reload
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
import {
AppBaseProviders,
AppInterface,
PlatformProvider,
Platform,
useCommand,
handleNotificationClick,
type Platform,
PlatformProvider,
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Splash } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
import { UPDATER_ENABLED } from "./updater"
import { initI18n, t } from "./i18n"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { open, save } from "@tauri-apps/plugin-dialog"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
import { type as ostype } from "@tauri-apps/plugin-os"
import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { commands, InitStep } from "./bindings"
import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -58,7 +59,7 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
}
const createPlatform = (password: Accessor<string | null>): Platform => {
const createPlatform = (): Platform => {
const os = (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
@@ -344,22 +345,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
},
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
return tauriFetch(input, init)
}
},
@@ -417,7 +406,11 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return resolve(null)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
resolve(
new File([blob], `pasted-image-${Date.now()}.png`, {
type: "image/png",
}),
)
}, "image/png")
})
},
@@ -431,9 +424,7 @@ createMenu((id) => {
void listenForDeepLinks()
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
const platform = createPlatform()
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -455,9 +446,16 @@ render(() => {
<AppBaseProviders>
<ServerGate>
{(data) => {
setServerPassword(data().password)
window.__OPENCODE__ ??= {}
window.__OPENCODE__.serverPassword = data().password ?? undefined
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
}
function Inner() {
const cmd = useCommand()
@@ -468,7 +466,7 @@ render(() => {
}
return (
<AppInterface defaultUrl={data().url} isSidecar>
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]}>
<Inner />
</AppInterface>
)

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst"
declare module "sst" {

View File

@@ -40,6 +40,7 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
@@ -74,7 +75,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.5.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -110,6 +111,7 @@
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",

View File

@@ -214,7 +214,8 @@ export default {
},
{
filetype: "clojure",
wasm: "https://github.com/sogaiu/tree-sitter-clojure/releases/download/v0.0.13/tree-sitter-clojure.wasm",
// temporarily using fork to fix issues
wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",

View File

@@ -52,13 +52,13 @@ export namespace Agent {
const cfg = await Config.get()
const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
[Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
@@ -142,7 +142,8 @@ export namespace Agent {
codesearch: "allow",
read: "allow",
external_directory: {
[Truncate.GLOB]: "allow",
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,

View File

@@ -66,14 +66,14 @@ export namespace BunProc {
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(async () => {
const result = { dependencies: {} }
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
@@ -123,15 +123,16 @@ export namespace BunProc {
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkgJson = Bun.file(path.join(mod, "package.json"))
const installedPkg = await installedPkgJson.json().catch(() => null)
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}

View File

@@ -1,5 +1,6 @@
import path from "path"
import { exec } from "child_process"
import { Filesystem } from "../../util/filesystem"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
import { Octokit } from "@octokit/rest"
@@ -173,6 +174,18 @@ export function extractResponseText(parts: MessageV2.Part[]): string | null {
throw new Error("Failed to parse response: no parts returned")
}
/**
* Formats a PROMPT_TOO_LARGE error message with details about files in the prompt.
* Content is base64 encoded, so we calculate original size by multiplying by 0.75.
*/
export function formatPromptTooLargeError(files: { filename: string; content: string }[]): string {
const fileDetails =
files.length > 0
? `\n\nFiles in prompt:\n${files.map((f) => ` - ${f.filename} (${((f.content.length * 0.75) / 1024).toFixed(0)} KB)`).join("\n")}`
: ""
return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}`
}
export const GithubCommand = cmd({
command: "github",
describe: "manage GitHub agent",
@@ -360,7 +373,7 @@ export const GithubInstallCommand = cmd({
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
await Bun.write(
await Filesystem.write(
path.join(app.root, WORKFLOW_FILE),
`name: opencode
@@ -802,6 +815,7 @@ export const GithubRunCommand = cmd({
replacement,
})
}
return { userPrompt: prompt, promptFiles: imgData }
}
@@ -909,10 +923,15 @@ export const GithubRunCommand = cmd({
// result should always be assistant just satisfying type checker
if (result.info.role === "assistant" && result.info.error) {
console.error("Agent error:", result.info.error)
throw new Error(
`${result.info.error.name}: ${"message" in result.info.error ? result.info.error.message : ""}`,
)
const err = result.info.error
console.error("Agent error:", err)
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
const text = extractResponseText(result.parts)
@@ -938,10 +957,15 @@ export const GithubRunCommand = cmd({
})
if (summary.info.role === "assistant" && summary.info.error) {
console.error("Summary agent error:", summary.info.error)
throw new Error(
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
)
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
const summaryText = extractResponseText(summary.parts)

View File

@@ -13,6 +13,7 @@ import { Installation } from "../../installation"
import path from "path"
import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
@@ -388,7 +389,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
}
for (const candidate of candidates) {
if (await Bun.file(candidate).exists()) {
if (await Filesystem.exists(candidate)) {
return candidate
}
}
@@ -398,11 +399,9 @@ async function resolveConfigPath(baseDir: string, global = false) {
}
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
const file = Bun.file(configPath)
let text = "{}"
if (await file.exists()) {
text = await file.text()
if (await Filesystem.exists(configPath)) {
text = await Filesystem.readText(configPath)
}
// Use jsonc-parser to modify while preserving comments
@@ -411,7 +410,7 @@ async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: s
})
const result = applyEdits(text, edits)
await Bun.write(configPath, result)
await Filesystem.write(configPath, result)
return configPath
}

View File

@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
@@ -315,19 +316,12 @@ export const RunCommand = cmd({
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
const file = Bun.file(resolvedPath)
const stats = await file.stat().catch(() => {})
if (!stats) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
}
if (!(await file.exists())) {
if (!(await Filesystem.exists(resolvedPath))) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
}
const stat = await file.stat()
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
files.push({
type: "file",

View File

@@ -85,26 +85,17 @@ export const SessionListCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = []
for await (const session of Session.list()) {
if (!session.parentID) {
sessions.push(session)
}
}
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
sessions.sort((a, b) => b.time.updated - a.time.updated)
const limitedSessions = args.maxCount ? sessions.slice(0, args.maxCount) : sessions
if (limitedSessions.length === 0) {
if (sessions.length === 0) {
return
}
let output: string
if (args.format === "json") {
output = formatSessionJSON(limitedSessions)
output = formatSessionJSON(sessions)
} else {
output = formatSessionTable(limitedSessions)
output = formatSessionTable(sessions)
}
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"

View File

@@ -694,7 +694,7 @@ export function Prompt(props: PromptProps) {
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
const count = store.prompt.parts.filter((x) => x.type === "file").length
const count = store.prompt.parts.filter((x) => x.type === "file" && x.mime.startsWith("image/")).length
const virtualText = `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "

View File

@@ -4,6 +4,7 @@ import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import { Filesystem } from "../../../../util/filesystem"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -34,9 +35,8 @@ export namespace Clipboard {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const file = Bun.file(tmpfile)
const buffer = await file.arrayBuffer()
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()

View File

@@ -3,6 +3,7 @@ import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
@@ -12,7 +13,7 @@ export namespace Editor {
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
await Bun.write(filepath, opts.value)
await Filesystem.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ")
@@ -23,7 +24,7 @@ export namespace Editor {
stderr: "inherit",
})
await proc.exited
const content = await Bun.file(filepath).text()
const content = await Filesystem.readText(filepath)
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()

View File

@@ -7,6 +7,7 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Filesystem } from "../../util/filesystem"
interface UninstallArgs {
keepConfig: boolean
@@ -267,9 +268,7 @@ async function getShellConfigFile(): Promise<string | null> {
.catch(() => false)
if (!exists) continue
const content = await Bun.file(file)
.text()
.catch(() => "")
const content = await Filesystem.readText(file).catch(() => "")
if (content.includes("# opencode") || content.includes(".opencode/bin")) {
return file
}
@@ -279,7 +278,7 @@ async function getShellConfigFile(): Promise<string | null> {
}
async function cleanShellConfig(file: string) {
const content = await Bun.file(file).text()
const content = await Filesystem.readText(file)
const lines = content.split("\n")
const filtered: string[] = []
@@ -315,7 +314,7 @@ async function cleanShellConfig(file: string) {
}
const output = filtered.join("\n") + "\n"
await Bun.write(file, output)
await Filesystem.write(file, output)
}
async function getDirectorySize(dir: string): Promise<number> {

View File

@@ -255,19 +255,20 @@ export namespace Config {
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Bun.file(pkg)
.json()
.catch(() => ({}))
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
"@opencode-ai/plugin": targetVersion,
}
await Bun.write(pkg, JSON.stringify(json, null, 2))
await Filesystem.writeJson(pkg, json)
await new Promise((resolve) => setTimeout(resolve, 3000))
const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Bun.file(gitignore).exists()
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
@@ -303,11 +304,10 @@ export namespace Config {
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgFile = Bun.file(pkg)
const pkgExists = await pkgFile.exists()
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await pkgFile.json().catch(() => null)
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
@@ -1220,7 +1220,7 @@ export namespace Config {
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result)
await fs.unlink(legacy)
})
.catch(() => {})
@@ -1231,12 +1231,10 @@ export namespace Config {
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
let text = await Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
let text = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {}
return load(text, filepath)
}
@@ -1263,21 +1261,19 @@ export namespace Config {
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Bun.file(resolvedPath)
.text()
.catch((error) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configFilepath,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
})
await Filesystem.readText(resolvedPath).catch((error: any) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configFilepath,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
})
).trim()
// escape newlines/quotes, strip outer quotes
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
@@ -1314,7 +1310,7 @@ export namespace Config {
parsed.data.$schema = "https://opencode.ai/config.json"
// Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(configFilepath, updated).catch(() => {})
await Filesystem.write(configFilepath, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin) {
@@ -1370,7 +1366,7 @@ export namespace Config {
export async function update(config: Info) {
const filepath = path.join(Instance.directory, "config.json")
const existing = await loadFile(filepath)
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
await Filesystem.writeJson(filepath, mergeDeep(existing, config))
await Instance.dispose()
}
@@ -1441,24 +1437,22 @@ export namespace Config {
export async function updateGlobal(config: Info) {
const filepath = globalConfigFile()
const before = await Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: filepath }, { cause: err })
})
const before = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: filepath }, { cause: err })
})
const next = await (async () => {
if (!filepath.endsWith(".jsonc")) {
const existing = parseConfig(before, filepath)
const merged = mergeDeep(existing, config)
await Bun.write(filepath, JSON.stringify(merged, null, 2))
await Filesystem.writeJson(filepath, merged)
return merged
}
const updated = patchJsonc(before, config)
const merged = parseConfig(updated, filepath)
await Bun.write(filepath, updated)
await Filesystem.write(filepath, updated)
return merged
})()

View File

@@ -1,6 +1,7 @@
import { NamedError } from "@opencode-ai/util/error"
import matter from "gray-matter"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
export namespace ConfigMarkdown {
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
@@ -68,7 +69,7 @@ export namespace ConfigMarkdown {
}
export async function parse(filePath: string) {
const template = await Bun.file(filePath).text()
const template = await Filesystem.readText(filePath)
try {
const md = matter(template)

View File

@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { $ } from "bun"
import type { BunFile } from "bun"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
@@ -241,8 +240,8 @@ export namespace File {
return mimeType.startsWith("image/")
}
async function shouldEncode(file: BunFile): Promise<boolean> {
const type = file.type?.toLowerCase()
async function shouldEncode(mimeType: string): Promise<boolean> {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
@@ -385,7 +384,7 @@ export namespace File {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(path.join(Instance.directory, filepath)).text()
const content = await Filesystem.readText(path.join(Instance.directory, filepath))
const lines = content.split("\n").length
changedFiles.push({
path: filepath,
@@ -437,10 +436,9 @@ export namespace File {
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
const bunFile = Bun.file(full)
if (await bunFile.exists()) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
const mimeType = getImageMimeType(file)
return { type: "text", content, mimeType, encoding: "base64" }
}
@@ -451,29 +449,24 @@ export namespace File {
return { type: "binary", content: "" }
}
const bunFile = Bun.file(full)
if (!(await bunFile.exists())) {
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const encode = await shouldEncode(bunFile)
const mimeType = bunFile.type || "application/octet-stream"
const mimeType = Filesystem.mimeType(full)
const encode = await shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
return { type: "text", content, mimeType, encoding: "base64" }
}
const content = await bunFile
.text()
.catch(() => "")
.then((x) => x.trim())
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
@@ -497,13 +490,13 @@ export namespace File {
let ignored = (_: string) => false
if (project.vcs === "git") {
const ig = ignore()
const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
if (await gitignore.exists()) {
ig.add(await gitignore.text())
const gitignorePath = path.join(Instance.worktree, ".gitignore")
if (await Filesystem.exists(gitignorePath)) {
ig.add(await Filesystem.readText(gitignorePath))
}
const ignoreFile = Bun.file(path.join(Instance.worktree, ".ignore"))
if (await ignoreFile.exists()) {
ig.add(await ignoreFile.text())
const ignorePath = path.join(Instance.worktree, ".ignore")
if (await Filesystem.exists(ignorePath)) {
ig.add(await Filesystem.readText(ignorePath))
}
ignored = ig.ignores.bind(ig)
}

View File

@@ -6,6 +6,7 @@ import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log"
@@ -131,8 +132,7 @@ export namespace Ripgrep {
}
const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
if (!(await Filesystem.exists(filepath))) {
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
@@ -144,9 +144,9 @@ export namespace Ripgrep {
const response = await fetch(url)
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const arrayBuffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer)
await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
@@ -166,7 +166,7 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
@@ -190,7 +190,7 @@ export namespace Ripgrep {
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
await zipFileReader.close()
}
await fs.unlink(archivePath)

View File

@@ -67,7 +67,10 @@ export const prettier: Info = {
async enabled() {
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Bun.file(item).json()
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.prettier) return true
if (json.devDependencies?.prettier) return true
}
@@ -86,7 +89,10 @@ export const oxfmt: Info = {
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Bun.file(item).json()
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.oxfmt) return true
if (json.devDependencies?.oxfmt) return true
}
@@ -179,7 +185,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Bun.file(found[0]).text()
const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true
} else {
return true
@@ -190,7 +196,7 @@ export const ruff: Info = {
for (const dep of deps) {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Bun.file(found[0]).text()
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true
}
}
@@ -348,7 +354,10 @@ export const pint: Info = {
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Bun.file(item).json()
const json = await Filesystem.readJson<{
require?: Record<string, string>
"require-dev"?: Record<string, string>
}>(item)
if (json.require?.["laravel/pint"]) return true
if (json["require-dev"]?.["laravel/pint"]) return true
}

View File

@@ -2,6 +2,7 @@ import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
import os from "os"
import { Filesystem } from "../util/filesystem"
const app = "opencode"
@@ -35,9 +36,7 @@ await Promise.all([
const CACHE_VERSION = "21"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
.catch(() => "0")
const version = await Filesystem.readText(path.join(Global.Path.cache, "version")).catch(() => "0")
if (version !== CACHE_VERSION) {
try {
@@ -51,5 +50,5 @@ if (version !== CACHE_VERSION) {
),
)
} catch (e) {}
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
await Filesystem.write(path.join(Global.Path.cache, "version"), CACHE_VERSION)
}

View File

@@ -13,6 +13,7 @@ import { Installation } from "./installation"
import { NamedError } from "@opencode-ai/util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { Filesystem } from "./util/filesystem"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
@@ -81,7 +82,7 @@ const cli = yargs(hideBin(process.argv))
})
const marker = path.join(Global.Path.data, "opencode.db")
if (!(await Bun.file(marker).exists())) {
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
const width = 36

View File

@@ -44,6 +44,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".htm": "html",
".ini": "ini",
".java": "java",
".jl": "julia",
".js": "javascript",
".kt": "kotlin",
".kts": "kotlin",

View File

@@ -2043,4 +2043,22 @@ export namespace LSPServer {
}
},
}
export const JuliaLS: Info = {
id: "julials",
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
async spawn(root) {
const julia = Bun.which("julia")
if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return
}
return {
process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], {
cwd: root,
}),
}
},
}
}

View File

@@ -1,6 +1,7 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util/log"
export namespace Patch {
@@ -307,18 +308,16 @@ export namespace Patch {
content: string
}
export async function deriveNewContentsFromChunks(
filePath: string,
chunks: UpdateFileChunk[],
originalContent?: string,
): Promise<ApplyPatchFileUpdate> {
const content =
originalContent ??
(await fs.readFile(filePath, "utf-8").catch((error) => {
throw new Error(`Failed to read file ${filePath}: ${error}`)
}))
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: string
try {
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`)
}
let originalLines = content.split("\n")
let originalLines = originalContent.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
@@ -336,7 +335,7 @@ export namespace Patch {
const newContent = newLines.join("\n")
// Generate unified diff
const unifiedDiff = generateUnifiedDiff(content, newContent)
const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
return {
unified_diff: unifiedDiff,
@@ -546,7 +545,7 @@ export namespace Patch {
break
case "update":
const fileUpdate = await deriveNewContentsFromChunks(hunk.path, hunk.chunks)
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) {
// Handle file move
@@ -642,7 +641,7 @@ export namespace Patch {
case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path)
try {
const fileUpdate = await deriveNewContentsFromChunks(updatePath, hunk.chunks)
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,

View File

@@ -101,7 +101,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
// Apply the update chunks to get new content
try {
const fileUpdate = await Patch.deriveNewContentsFromChunks(filePath, hunk.chunks, oldContent)
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)

View File

@@ -1,18 +1,76 @@
import { mkdir, readFile, writeFile } from "fs/promises"
import { existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative } from "path"
export namespace Filesystem {
export const exists = (p: string) =>
Bun.file(p)
.stat()
.then(() => true)
.catch(() => false)
// Fast sync version for metadata checks
export async function exists(p: string): Promise<boolean> {
return existsSync(p)
}
export async function isDir(p: string): Promise<boolean> {
try {
return statSync(p).isDirectory()
} catch {
return false
}
}
export async function size(p: string): Promise<number> {
try {
return statSync(p).size
} catch {
return 0
}
}
export async function readText(p: string): Promise<string> {
return readFile(p, "utf-8")
}
export async function readJson<T = any>(p: string): Promise<T> {
return JSON.parse(await readFile(p, "utf-8"))
}
export async function readBytes(p: string): Promise<Buffer> {
return readFile(p)
}
function isEnoent(e: unknown): e is { code: "ENOENT" } {
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
}
export async function write(p: string, content: string | Buffer, mode?: number): Promise<void> {
try {
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
} catch (e) {
if (isEnoent(e)) {
await mkdir(dirname(p), { recursive: true })
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
return
}
throw e
}
}
export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
return write(p, JSON.stringify(data, null, 2), mode)
}
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
export const isDir = (p: string) =>
Bun.file(p)
.stat()
.then((s) => s.isDirectory())
.catch(() => false)
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
@@ -26,6 +84,7 @@ export namespace Filesystem {
return p
}
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />

View File

@@ -75,6 +75,20 @@ test("explore agent denies edit and write", async () => {
})
})
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
expect(explore).toBeDefined()
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
},
})
})
test("general agent denies todo tools", async () => {
await using tmp = await tmpdir()
await Instance.provide({

View File

@@ -1,5 +1,5 @@
import { test, expect, describe } from "bun:test"
import { extractResponseText } from "../../src/cli/cmd/github"
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
import type { MessageV2 } from "../../src/session/message-v2"
// Helper to create minimal valid parts
@@ -159,3 +159,39 @@ describe("extractResponseText", () => {
expect(extractResponseText(parts)).toBe("Here's what I found")
})
})
describe("formatPromptTooLargeError", () => {
test("formats error without files", () => {
const result = formatPromptTooLargeError([])
expect(result).toBe("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
})
test("formats error with files (base64 content)", () => {
// Base64 is ~33% larger than original, so we multiply by 0.75 to get original size
// 400 KB base64 = 300 KB original, 200 KB base64 = 150 KB original
const files = [
{ filename: "screenshot.png", content: "a".repeat(400 * 1024) },
{ filename: "diagram.png", content: "b".repeat(200 * 1024) },
]
const result = formatPromptTooLargeError(files)
expect(result).toStartWith("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
expect(result).toInclude("Files in prompt:")
expect(result).toInclude("screenshot.png (300 KB)")
expect(result).toInclude("diagram.png (150 KB)")
})
test("lists all files when multiple present", () => {
// Base64 sizes: 4KB -> 3KB, 8KB -> 6KB, 12KB -> 9KB
const files = [
{ filename: "img1.png", content: "x".repeat(4 * 1024) },
{ filename: "img2.jpg", content: "y".repeat(8 * 1024) },
{ filename: "img3.gif", content: "z".repeat(12 * 1024) },
]
const result = formatPromptTooLargeError(files)
expect(result).toInclude("img1.png (3 KB)")
expect(result).toInclude("img2.jpg (6 KB)")
expect(result).toInclude("img3.gif (9 KB)")
})
})

View File

@@ -0,0 +1,340 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("file/index Bun.file patterns", () => {
describe("File.read() - text content", () => {
test("reads text file via Bun.file().text()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "Hello World", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("Hello World")
},
})
})
test("reads with Bun.file().exists() check", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Non-existent file should return empty content
const result = await File.read("nonexistent.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
})
})
test("trims whitespace from text content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
expect(result.content).toBe("content with spaces")
},
})
})
test("handles empty text file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "empty.txt")
await fs.writeFile(filepath, "", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("empty.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
})
})
test("handles multi-line text files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "multiline.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
},
})
})
})
describe("File.read() - binary content", () => {
test("reads binary file via Bun.file().arrayBuffer()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "image.png")
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await fs.writeFile(filepath, binaryContent)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("image.png")
expect(result.type).toBe("text") // Images return as text with base64 encoding
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/png")
expect(result.content).toBe(binaryContent.toString("base64"))
},
})
})
test("returns empty for binary non-image files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "binary.so")
await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("binary.so")
expect(result.type).toBe("binary")
expect(result.content).toBe("")
},
})
})
})
describe("File.read() - Bun.file().type", () => {
test("detects MIME type via Bun.file().type", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.json")
await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bunFile = Bun.file(filepath)
expect(bunFile.type).toContain("application/json")
const result = await File.read("test.json")
expect(result.type).toBe("text")
},
})
})
test("handles various image MIME types", async () => {
await using tmp = await tmpdir()
const testCases = [
{ ext: "jpg", mime: "image/jpeg" },
{ ext: "png", mime: "image/png" },
{ ext: "gif", mime: "image/gif" },
{ ext: "webp", mime: "image/webp" },
]
for (const { ext, mime } of testCases) {
const filepath = path.join(tmp.path, `test.${ext}`)
await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bunFile = Bun.file(filepath)
expect(bunFile.type).toContain(mime)
},
})
}
})
})
describe("File.list() - Bun.file().exists() and .text()", () => {
test("reads .gitignore via Bun.file().exists() and .text()", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
// This is used internally in File.list()
const bunFile = Bun.file(gitignorePath)
expect(await bunFile.exists()).toBe(true)
const content = await bunFile.text()
expect(content).toContain("node_modules")
},
})
})
test("reads .ignore file similarly", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ignorePath = path.join(tmp.path, ".ignore")
await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
const bunFile = Bun.file(ignorePath)
expect(await bunFile.exists()).toBe(true)
expect(await bunFile.text()).toContain("*.log")
},
})
})
test("handles missing .gitignore gracefully", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
const bunFile = Bun.file(gitignorePath)
expect(await bunFile.exists()).toBe(false)
// File.list() should still work
const nodes = await File.list()
expect(Array.isArray(nodes)).toBe(true)
},
})
})
})
describe("File.changed() - Bun.file().text() for untracked files", () => {
test("reads untracked files via Bun.file().text()", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const untrackedPath = path.join(tmp.path, "untracked.txt")
await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
// This is how File.changed() reads untracked files
const bunFile = Bun.file(untrackedPath)
const content = await bunFile.text()
const lines = content.split("\n").length
expect(lines).toBe(2)
},
})
})
})
describe("Error handling", () => {
test("handles errors gracefully in Bun.file().text()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "readonly.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt"))
// Bun.file().text() on non-existent file throws
await expect(nonExistentFile.text()).rejects.toThrow()
// But File.read() handles this gracefully
const result = await File.read("does-not-exist.txt")
expect(result.content).toBe("")
},
})
})
test("handles errors in Bun.file().arrayBuffer()", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin"))
const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0))
expect(buffer.byteLength).toBe(0)
},
})
})
test("returns empty array buffer on error for images", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "broken.png")
// Don't create the file
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bunFile = Bun.file(filepath)
// File.read() handles missing images gracefully
const result = await File.read("broken.png")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
})
})
})
describe("shouldEncode() logic", () => {
test("returns encoding info for text files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "simple text", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
expect(result.encoding).toBeUndefined()
expect(result.type).toBe("text")
},
})
})
test("returns base64 encoding for images", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.jpg")
await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.jpg")
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/jpeg")
},
})
})
})
describe("Path security", () => {
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
})
})

View File

@@ -0,0 +1,360 @@
import { describe, test, expect, beforeEach } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { FileTime } from "../../src/file/time"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("file/time", () => {
const sessionID = "test-session-123"
describe("read() and get()", () => {
test("stores read timestamp", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = FileTime.get(sessionID, filepath)
expect(before).toBeUndefined()
FileTime.read(sessionID, filepath)
const after = FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0)
},
})
})
test("tracks separate timestamps per session", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read("session1", filepath)
FileTime.read("session2", filepath)
const time1 = FileTime.get("session1", filepath)
const time2 = FileTime.get("session2", filepath)
expect(time1).toBeDefined()
expect(time2).toBeDefined()
},
})
})
test("updates timestamp on subsequent reads", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
const first = FileTime.get(sessionID, filepath)!
await new Promise((resolve) => setTimeout(resolve, 10))
FileTime.read(sessionID, filepath)
const second = FileTime.get(sessionID, filepath)!
expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
},
})
})
})
describe("assert()", () => {
test("passes when file has not been modified", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
// Should not throw
await FileTime.assert(sessionID, filepath)
},
})
})
test("throws when file was not read first", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
},
})
})
test("throws when file was modified after read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
// Wait to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Modify file after reading
await fs.writeFile(filepath, "modified content", "utf-8")
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
},
})
})
test("includes timestamps in error message", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8")
let error: Error | undefined
try {
await FileTime.assert(sessionID, filepath)
} catch (e) {
error = e as Error
}
expect(error).toBeDefined()
expect(error!.message).toContain("Last modification:")
expect(error!.message).toContain("Last read:")
},
})
})
test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Flag } = await import("../../src/flag/flag")
const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
try {
// Should not throw even though file wasn't read
await FileTime.assert(sessionID, filepath)
} finally {
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
}
},
})
})
})
describe("withLock()", () => {
test("executes function within lock", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
return "result"
})
expect(executed).toBe(true)
},
})
})
test("returns function result", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await FileTime.withLock(filepath, async () => {
return "success"
})
expect(result).toBe("success")
},
})
})
test("serializes concurrent operations on same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const order: number[] = []
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
await new Promise((resolve) => setTimeout(resolve, 10))
order.push(2)
})
const op2 = FileTime.withLock(filepath, async () => {
order.push(3)
order.push(4)
})
await Promise.all([op1, op2])
// Operations should be serialized
expect(order).toContain(1)
expect(order).toContain(2)
expect(order).toContain(3)
expect(order).toContain(4)
},
})
})
test("allows concurrent operations on different files", async () => {
await using tmp = await tmpdir()
const filepath1 = path.join(tmp.path, "file1.txt")
const filepath2 = path.join(tmp.path, "file2.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
let started1 = false
let started2 = false
const op1 = FileTime.withLock(filepath1, async () => {
started1 = true
await new Promise((resolve) => setTimeout(resolve, 50))
expect(started2).toBe(true) // op2 should have started while op1 is running
})
const op2 = FileTime.withLock(filepath2, async () => {
started2 = true
})
await Promise.all([op1, op2])
expect(started1).toBe(true)
expect(started2).toBe(true)
},
})
})
test("releases lock even if function throws", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
FileTime.withLock(filepath, async () => {
throw new Error("Test error")
}),
).rejects.toThrow("Test error")
// Lock should be released, subsequent operations should work
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
})
expect(executed).toBe(true)
},
})
})
test("deadlocks on nested locks (expected behavior)", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Nested locks on same file cause deadlock - this is expected
// The outer lock waits for inner to complete, but inner waits for outer to release
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Deadlock detected")), 100),
)
const nestedLock = FileTime.withLock(filepath, async () => {
return FileTime.withLock(filepath, async () => {
return "inner"
})
})
// Should timeout due to deadlock
await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
},
})
})
})
describe("stat() Bun.file pattern", () => {
test("reads file modification time via Bun.file().stat()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
const stats = await Bun.file(filepath).stat()
expect(stats.mtime).toBeInstanceOf(Date)
expect(stats.mtime.getTime()).toBeGreaterThan(0)
// FileTime.assert uses this stat internally
await FileTime.assert(sessionID, filepath)
},
})
})
test("detects modification via stat mtime", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
const originalStat = await Bun.file(filepath).stat()
// Wait and modify
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8")
const newStat = await Bun.file(filepath).stat()
expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime())
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
},
})
})
})
})

View File

@@ -0,0 +1,496 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time"
const ctx = {
sessionID: "test-edit-session",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
describe("tool.edit", () => {
describe("creating new files", () => {
test("creates new file when oldString is empty", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "newfile.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "new content",
},
ctx,
)
expect(result.metadata.diff).toContain("new content")
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content")
},
})
})
test("creates new file with nested directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "nested file",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("nested file")
},
})
})
test("emits add event for new files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "new.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "content",
},
ctx,
)
expect(events).toContain("edited")
expect(events).toContain("updated")
unsubEdited()
unsubUpdated()
},
})
})
})
describe("editing existing files", () => {
test("replaces text in existing file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.txt")
await fs.writeFile(filepath, "old content here", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "old content",
newString: "new content",
},
ctx,
)
expect(result.output).toContain("Edit applied successfully")
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content here")
},
})
})
test("throws error when file does not exist", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nonexistent.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "old",
newString: "new",
},
ctx,
),
).rejects.toThrow("not found")
},
})
})
test("throws error when oldString equals newString", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "same",
newString: "same",
},
ctx,
),
).rejects.toThrow("identical")
},
})
})
test("throws error when oldString not found in file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "actual content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "not in file",
newString: "replacement",
},
ctx,
),
).rejects.toThrow()
},
})
})
test("throws error when file was not read first (FileTime)", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "content",
newString: "modified",
},
ctx,
),
).rejects.toThrow("You must read file")
},
})
})
test("throws error when file has been modified since read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Read first
FileTime.read(ctx.sessionID, filepath)
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Simulate external modification
await fs.writeFile(filepath, "modified externally", "utf-8")
// Try to edit with the new content
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "modified externally",
newString: "edited",
},
ctx,
),
).rejects.toThrow("modified since it was last read")
},
})
})
test("replaces all occurrences with replaceAll option", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "foo",
newString: "qux",
replaceAll: true,
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("qux bar qux baz qux")
},
})
})
test("emits change event for existing files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "original",
newString: "modified",
},
ctx,
)
expect(events).toContain("edited")
expect(events).toContain("updated")
unsubEdited()
unsubUpdated()
},
})
})
})
describe("edge cases", () => {
test("handles multiline replacements", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "line2",
newString: "new line 2\nextra line",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
},
})
})
test("handles CRLF line endings", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "old",
newString: "new",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\r\nnew\r\nline3")
},
})
})
test("throws error when oldString equals newString", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "",
},
ctx,
),
).rejects.toThrow("identical")
},
})
})
test("throws error when path is directory", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "adir")
await fs.mkdir(dirpath)
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, dirpath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: dirpath,
oldString: "old",
newString: "new",
},
ctx,
),
).rejects.toThrow("directory")
},
})
})
test("tracks file diff statistics", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "line2",
newString: "new line a\nnew line b",
},
ctx,
)
expect(result.metadata.filediff).toBeDefined()
expect(result.metadata.filediff.file).toBe(filepath)
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
},
})
})
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "0", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
// Two concurrent edits
const promise1 = edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "1",
},
ctx,
)
// Need to read again since FileTime tracks per-session
FileTime.read(ctx.sessionID, filepath)
const promise2 = edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "2",
},
ctx,
)
// Both should complete without error (though one might fail due to content mismatch)
const results = await Promise.allSettled([promise1, promise2])
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
},
})
})
})
})

View File

@@ -0,0 +1,341 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const ctx = {
sessionID: "test-write-session",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
describe("tool.write", () => {
describe("new file creation", () => {
test("writes content to new file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "newfile.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "Hello, World!",
},
ctx,
)
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(false)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("Hello, World!")
},
})
})
test("creates parent directories if needed", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: "nested content",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("nested content")
},
})
})
test("handles relative paths by resolving to instance directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: "relative.txt",
content: "relative content",
},
ctx,
)
const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
expect(content).toBe("relative content")
},
})
})
})
describe("existing file overwrite", () => {
test("overwrites existing file content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.txt")
await fs.writeFile(filepath, "old content", "utf-8")
// First read the file to satisfy FileTime requirement
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")
FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "new content",
},
ctx,
)
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(true)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content")
},
})
})
test("returns diff in metadata for existing files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "old", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")
FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "new",
},
ctx,
)
// Diff should be in metadata
expect(result.metadata).toHaveProperty("filepath", filepath)
expect(result.metadata).toHaveProperty("exists", true)
},
})
})
})
describe("file permissions", () => {
test("sets file permissions when writing sensitive data", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "sensitive.json")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: JSON.stringify({ secret: "data" }),
},
ctx,
)
// On Unix systems, check permissions
if (process.platform !== "win32") {
const stats = await fs.stat(filepath)
expect(stats.mode & 0o777).toBe(0o644)
}
},
})
})
})
describe("content types", () => {
test("writes JSON content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "data.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: JSON.stringify(data, null, 2),
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(JSON.parse(content)).toEqual(data)
},
})
})
test("writes binary-safe content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "binary.bin")
const content = "Hello\x00World\x01\x02\x03"
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content,
},
ctx,
)
const buf = await fs.readFile(filepath)
expect(buf.toString()).toBe(content)
},
})
})
test("writes empty content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "empty.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: "",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("")
const stats = await fs.stat(filepath)
expect(stats.size).toBe(0)
},
})
})
test("writes multi-line content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "multiline.txt")
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: lines,
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe(lines)
},
})
})
test("handles different line endings", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "crlf.txt")
const content = "Line 1\r\nLine 2\r\nLine 3"
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content,
},
ctx,
)
const buf = await fs.readFile(filepath)
expect(buf.toString()).toBe(content)
},
})
})
})
describe("error handling", () => {
test("throws error for paths outside project", async () => {
await using tmp = await tmpdir()
const outsidePath = "/etc/passwd"
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await expect(
write.execute(
{
filePath: outsidePath,
content: "test",
},
ctx,
),
).rejects.toThrow()
},
})
})
})
describe("title generation", () => {
test("returns relative path as title", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
await fs.mkdir(path.dirname(filepath), { recursive: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "export const Button = () => {}",
},
ctx,
)
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
},
})
})
})
})

View File

@@ -1,39 +1,288 @@
import { describe, expect, test } from "bun:test"
import os from "node:os"
import path from "node:path"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
describe("util.filesystem", () => {
test("exists() is true for files and directories", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
const dir = path.join(tmp, "dir")
const file = path.join(tmp, "file.txt")
const missing = path.join(tmp, "missing")
describe("filesystem", () => {
describe("exists()", () => {
test("returns true for existing file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "content", "utf-8")
await mkdir(dir, { recursive: true })
await Bun.write(file, "hello")
expect(await Filesystem.exists(filepath)).toBe(true)
})
const cases = await Promise.all([Filesystem.exists(dir), Filesystem.exists(file), Filesystem.exists(missing)])
test("returns false for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.txt")
expect(cases).toEqual([true, true, false])
expect(await Filesystem.exists(filepath)).toBe(false)
})
await rm(tmp, { recursive: true, force: true })
test("returns true for existing directory", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "subdir")
await fs.mkdir(dirpath)
expect(await Filesystem.exists(dirpath)).toBe(true)
})
})
test("isDir() is true only for directories", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
const dir = path.join(tmp, "dir")
const file = path.join(tmp, "file.txt")
const missing = path.join(tmp, "missing")
describe("isDir()", () => {
test("returns true for directory", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "testdir")
await fs.mkdir(dirpath)
await mkdir(dir, { recursive: true })
await Bun.write(file, "hello")
expect(await Filesystem.isDir(dirpath)).toBe(true)
})
const cases = await Promise.all([Filesystem.isDir(dir), Filesystem.isDir(file), Filesystem.isDir(missing)])
test("returns false for file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "content", "utf-8")
expect(cases).toEqual([true, false, false])
expect(await Filesystem.isDir(filepath)).toBe(false)
})
await rm(tmp, { recursive: true, force: true })
test("returns false for non-existent path", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist")
expect(await Filesystem.isDir(filepath)).toBe(false)
})
})
describe("size()", () => {
test("returns file size", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await fs.writeFile(filepath, content, "utf-8")
expect(await Filesystem.size(filepath)).toBe(content.length)
})
test("returns 0 for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.txt")
expect(await Filesystem.size(filepath)).toBe(0)
})
test("returns directory size", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "testdir")
await fs.mkdir(dirpath)
// Directories have size on some systems
const size = await Filesystem.size(dirpath)
expect(typeof size).toBe("number")
})
})
describe("readText()", () => {
test("reads file content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await fs.writeFile(filepath, content, "utf-8")
expect(await Filesystem.readText(filepath)).toBe(content)
})
test("throws for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.txt")
await expect(Filesystem.readText(filepath)).rejects.toThrow()
})
test("reads UTF-8 content correctly", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "unicode.txt")
const content = "Hello 世界 🌍"
await fs.writeFile(filepath, content, "utf-8")
expect(await Filesystem.readText(filepath)).toBe(content)
})
})
describe("readJson()", () => {
test("reads and parses JSON", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
const result: typeof data = await Filesystem.readJson(filepath)
expect(result).toEqual(data)
})
test("throws for invalid JSON", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "invalid.json")
await fs.writeFile(filepath, "{ invalid json", "utf-8")
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
})
test("throws for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.json")
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
})
test("returns typed data", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "typed.json")
interface Config {
name: string
version: number
}
const data: Config = { name: "test", version: 1 }
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
const result = await Filesystem.readJson<Config>(filepath)
expect(result.name).toBe("test")
expect(result.version).toBe(1)
})
})
describe("readBytes()", () => {
test("reads file as buffer", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await fs.writeFile(filepath, content, "utf-8")
const buffer = await Filesystem.readBytes(filepath)
expect(buffer).toBeInstanceOf(Buffer)
expect(buffer.toString("utf-8")).toBe(content)
})
test("throws for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.bin")
await expect(Filesystem.readBytes(filepath)).rejects.toThrow()
})
})
describe("write()", () => {
test("writes text content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await Filesystem.write(filepath, content)
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
test("writes buffer content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.bin")
const content = Buffer.from([0x00, 0x01, 0x02, 0x03])
await Filesystem.write(filepath, content)
const read = await fs.readFile(filepath)
expect(read).toEqual(content)
})
test("writes with permissions", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "protected.txt")
const content = "secret"
await Filesystem.write(filepath, content, 0o600)
const stats = await fs.stat(filepath)
// Check permissions on Unix
if (process.platform !== "win32") {
expect(stats.mode & 0o777).toBe(0o600)
}
})
test("creates parent directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
const content = "nested content"
await Filesystem.write(filepath, content)
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
})
describe("writeJson()", () => {
test("writes JSON data", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "data.json")
const data = { key: "value", number: 42 }
await Filesystem.writeJson(filepath, data)
const content = await fs.readFile(filepath, "utf-8")
expect(JSON.parse(content)).toEqual(data)
})
test("writes formatted JSON", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "pretty.json")
const data = { key: "value" }
await Filesystem.writeJson(filepath, data)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toContain("\n")
expect(content).toContain(" ")
})
test("writes with permissions", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "config.json")
const data = { secret: "data" }
await Filesystem.writeJson(filepath, data, 0o600)
const stats = await fs.stat(filepath)
if (process.platform !== "win32") {
expect(stats.mode & 0o777).toBe(0o600)
}
})
})
describe("mimeType()", () => {
test("returns correct MIME type for JSON", () => {
expect(Filesystem.mimeType("test.json")).toContain("application/json")
})
test("returns correct MIME type for JavaScript", () => {
expect(Filesystem.mimeType("test.js")).toContain("javascript")
})
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
const mime = Filesystem.mimeType("test.ts")
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
})
test("returns correct MIME type for images", () => {
expect(Filesystem.mimeType("test.png")).toContain("image/png")
expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
})
test("returns default for unknown extension", () => {
expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
})
test("handles files without extension", () => {
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
})
})
})

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />

View File

@@ -12,8 +12,18 @@
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts",
"./v2": "./src/v2/index.ts",
"./v2/client": "./src/v2/client.ts",
"./v2": {
"types": "./dist/src/v2/index.d.ts",
"default": "./src/v2/index.ts"
},
"./v2/client": {
"types": "./dist/src/v2/client.d.ts",
"default": "./src/v2/client.ts"
},
"./v2/gen/client": {
"types": "./dist/src/v2/gen/client/index.d.ts",
"default": "./src/v2/gen/client/index.ts"
},
"./v2/server": "./src/v2/server.ts"
},
"files": [

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" />

View File

@@ -6,8 +6,8 @@
"module": "nodenext",
"declaration": true,
"moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"]
"lib": ["es2022", "dom", "dom.iterable"],
"composite": true
},
"include": ["src"],
"exclude": ["src/gen"]
"include": ["src"]
}

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