Merge branch 'dev' into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory 2026-02-10 16:29:05 -05:00
commit eaf94ed047
862 changed files with 199010 additions and 6211 deletions

View file

@ -1,4 +1,4 @@
blank_issues_enabled: true blank_issues_enabled: false
contact_links: contact_links:
- name: 💬 Discord Community - name: 💬 Discord Community
url: https://discord.gg/opencode url: https://discord.gg/opencode

18
.github/VOUCHED.td vendored Normal file
View file

@ -0,0 +1,18 @@
# Vouched contributors for this project.
#
# See https://github.com/mitchellh/vouch for details.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: platform:username (e.g., github:user).
# - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle.
adamdotdevin
fwang
iamdavidhill
jayair
kitlangton
kommander
r44vc0rp
rekram1-node
thdxr

View file

@ -1,6 +1,6 @@
### What does this PR do? ### What does this PR do?
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the pr. Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**

86
.github/workflows/compliance-close.yml vendored Normal file
View file

@ -0,0 +1,86 @@
name: compliance-close
on:
schedule:
# Run every 30 minutes to check for expired compliance windows
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-non-compliant:
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 2 hours
uses: actions/github-script@v7
with:
script: |
const { data: items } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'needs:compliance',
state: 'open',
per_page: 100,
});
if (items.length === 0) {
core.info('No open issues/PRs with needs:compliance label');
return;
}
const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000;
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
});
const complianceComment = comments.find(c => c.body.includes('<!-- issue-compliance -->'));
if (!complianceComment) continue;
const commentAge = now - new Date(complianceComment.created_at).getTime();
if (commentAge < twoHours) {
core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
continue;
}
const closeMessage = isPR
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
body: closeMessage,
});
if (isPR) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: item.number,
state: 'closed',
});
} else {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`);
}

View file

@ -48,8 +48,12 @@ jobs:
TODAY'S DATE: ${TODAY} TODAY'S DATE: ${TODAY}
STEP 1: Gather today's issues STEP 1: Gather today's issues
Search for all issues created today (${TODAY}) using: Search for all OPEN issues created today (${TODAY}) using:
gh issue list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) issues only.
STEP 2: Analyze and categorize STEP 2: Analyze and categorize
For each issue created today, categorize it: For each issue created today, categorize it:

View file

@ -47,14 +47,18 @@ jobs:
TODAY'S DATE: ${TODAY} TODAY'S DATE: ${TODAY}
STEP 1: Gather PR data STEP 1: Gather PR data
Run these commands to gather PR information. ONLY include PRs created or updated TODAY (${TODAY}): Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
# PRs created today # Open PRs created today
gh pr list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
# PRs with activity today (updated today) # Open PRs with activity today (updated today)
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) contributions only.
STEP 2: For high-activity PRs, check comment counts STEP 2: For high-activity PRs, check comment counts

82
.github/workflows/docs-locale-sync.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: docs-locale-sync
on:
push:
branches:
- dev
paths:
- packages/web/src/content/docs/*.mdx
jobs:
sync-locales:
if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
id-token: write
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Compute changed English docs
id: changes
run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
{
echo "files<<EOF"
echo "$FILES"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Sync locale docs with OpenCode
if: steps.changes.outputs.has_changes == 'true'
uses: sst/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/gpt-5.2
agent: docs
prompt: |
Update localized docs to match the latest English docs changes.
Changed English doc files:
<changed_english_docs>
${{ steps.changes.outputs.files }}
</changed_english_docs>
Requirements:
1. Update all relevant locale docs under packages/web/src/content/docs/<locale>/ so they reflect these English page changes.
2. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
3. Keep locale docs structure aligned with their corresponding English pages.
4. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
5. If no locale updates are needed, make no changes.
- name: Commit and push locale docs updates
if: steps.changes.outputs.has_changes == 'true'
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No locale docs changes to commit"
exit 0
fi
git add -A
git commit -m "docs(i18n): sync locale docs from english changes"
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
git push origin HEAD:"$GITHUB_REF_NAME"

View file

@ -21,7 +21,7 @@ jobs:
- name: Install opencode - name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Check for duplicate issues - name: Check duplicates and compliance
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -34,30 +34,84 @@ jobs:
"webfetch": "deny" "webfetch": "deny"
} }
run: | run: |
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:' opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
Issue number: Issue number: ${{ github.event.issue.number }}
${{ github.event.issue.number }}
Lookup this issue and search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
---
TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
Check whether the issue follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
3. Question - requires the Question field with real content
Additionally check:
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
- The issue has real content, not just template placeholder text left unchanged
- Bug reports should include some context about how to reproduce
- Feature requests should explain the problem or need
- We want to push for having the user provide system description & information
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
---
TASK 2: DUPLICATE CHECK
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
Consider: Consider:
1. Similar titles or descriptions 1. Similar titles or descriptions
2. Same error messages or symptoms 2. Same error messages or symptoms
3. Related functionality or components 3. Related functionality or components
4. Similar feature requests 4. Similar feature requests
If you find any potential duplicates, please comment on the new issue with: Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
- A brief explanation of why it might be a duplicate
- Links to the potentially duplicate issues ---
- A suggestion to check those issues first
POSTING YOUR COMMENT:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
If the issue is NOT compliant, start the comment with:
<!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
If duplicates were found, include a section about potential duplicates with links.
If the issue mentions keybinds/keyboard shortcuts, include a note about #4997.
If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all.
Use this format for the comment: Use this format for the comment:
'This issue might be a duplicate of existing issues. Please check:
[If not compliant:]
<!-- issue-compliance -->
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
**What needs to be fixed:**
- [specific reasons]
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
[If duplicates found, add:]
---
This issue might be a duplicate of existing issues. Please check:
- #[issue_number]: [brief description of similarity] - #[issue_number]: [brief description of similarity]
Feel free to ignore if none of these address your specific case.' [If keybind-related, add:]
For keybind-related issues, please also check our pinned keybinds documentation: #4997
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, please add a comment mentioning the pinned keybinds issue #4997: [End with if not compliant:]
'For keybind-related issues, please also check our pinned keybinds documentation: #4997' If you believe this was flagged incorrectly, please let a maintainer know.
If no clear duplicates are found, do not comment." Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."

View file

@ -12,6 +12,9 @@ on:
- "package.json" - "package.json"
- "packages/*/package.json" - "packages/*/package.json"
- "flake.lock" - "flake.lock"
- "nix/node_modules.nix"
- "nix/scripts/**"
- "patches/**"
- ".github/workflows/nix-hashes.yml" - ".github/workflows/nix-hashes.yml"
jobs: jobs:

96
.github/workflows/vouch-check-issue.yml vendored Normal file
View file

@ -0,0 +1,96 @@
name: vouch-check-issue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if issue author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.issue.user.login;
const issueNumber = context.payload.issue.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for denounced users
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!trimmed.startsWith('-')) continue;
const rest = trimmed.slice(1).trim();
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
denounced.set(username.toLowerCase(), reason);
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason === undefined) {
core.info(`User ${author} is not denounced. Allowing issue.`);
return;
}
// Author is denounced — close the issue
const body = 'This issue has been automatically closed.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);

93
.github/workflows/vouch-check-pr.yml vendored Normal file
View file

@ -0,0 +1,93 @@
name: vouch-check-pr
on:
pull_request_target:
types: [opened]
permissions:
contents: read
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if PR author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.pull_request.user.login;
const prNumber = context.payload.pull_request.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for denounced users
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!trimmed.startsWith('-')) continue;
const rest = trimmed.slice(1).trim();
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
denounced.set(username.toLowerCase(), reason);
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason === undefined) {
core.info(`User ${author} is not denounced. Allowing PR.`);
return;
}
// Author is denounced — close the PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);

View file

@ -0,0 +1,37 @@
name: vouch-manage-by-issue
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: read
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- uses: mitchellh/vouch/action/manage-by-issue@main
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

View file

@ -0,0 +1,883 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3-pro
---
You are a professional translator and localization specialist.
Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE).
Requirements:
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
- Also preserve every term listed in the Do-Not-Translate glossary below.
- Do not modify fenced code blocks.
- Output ONLY the translation (no commentary).
If the target locale is missing, ask the user to provide it.
---
# Do-Not-Translate Terms (OpenCode Docs)
Generated from: `packages/web/src/content/docs/*.mdx` (default English docs)
Generated on: 2026-02-10
Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation).
General rules (verbatim, even if not listed below):
- Anything inside inline code (single backticks) or fenced code blocks (triple backticks)
- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers
- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars
## Proper nouns and product names
Additional (not reliably captured via link text):
```text
Astro
Bun
Chocolatey
Cursor
Docker
Git
GitHub Actions
GitLab CI
GNOME Terminal
Homebrew
Mise
Neovim
Node.js
npm
Obsidian
opencode
opencode-ai
Paru
pnpm
ripgrep
Scoop
SST
Starlight
Visual Studio Code
VS Code
VSCodium
Windsurf
Windows Terminal
Yarn
Zellij
Zed
anomalyco
```
Extracted from link labels in the English docs (review and prune as desired):
```text
@openspoon/subtask2
302.AI console
ACP progress report
Agent Client Protocol
Agent Skills
Agentic
AGENTS.md
AI SDK
Alacritty
Anthropic
Anthropic's Data Policies
Atom One
Avante.nvim
Ayu
Azure AI Foundry
Azure portal
Baseten
built-in GITHUB_TOKEN
Bun.$
Catppuccin
Cerebras console
ChatGPT Plus or Pro
Cloudflare dashboard
CodeCompanion.nvim
CodeNomad
Configuring Adapters: Environment Variables
Context7 MCP server
Cortecs console
Deep Infra dashboard
DeepSeek console
Duo Agent Platform
Everforest
Fireworks AI console
Firmware dashboard
Ghostty
GitLab CLI agents docs
GitLab docs
GitLab User Settings > Access Tokens
Granular Rules (Object Syntax)
Grep by Vercel
Groq console
Gruvbox
Helicone
Helicone documentation
Helicone Header Directory
Helicone's Model Directory
Hugging Face Inference Providers
Hugging Face settings
install WSL
IO.NET console
JetBrains IDE
Kanagawa
Kitty
MiniMax API Console
Models.dev
Moonshot AI console
Nebius Token Factory console
Nord
OAuth
Ollama integration docs
OpenAI's Data Policies
OpenChamber
OpenCode
OpenCode config
OpenCode Config
OpenCode TUI with the opencode theme
OpenCode Web - Active Session
OpenCode Web - New Session
OpenCode Web - See Servers
OpenCode Zen
OpenCode-Obsidian
OpenRouter dashboard
OpenWork
OVHcloud panel
Pro+ subscription
SAP BTP Cockpit
Scaleway Console IAM settings
Scaleway Generative APIs
SDK documentation
Sentry MCP server
shell API
Together AI console
Tokyonight
Unified Billing
Venice AI console
Vercel dashboard
WezTerm
Windows Subsystem for Linux (WSL)
WSL
WSL (Windows Subsystem for Linux)
WSL extension
xAI console
Z.AI API console
Zed
ZenMux dashboard
Zod
```
## Acronyms and initialisms
```text
ACP
AGENTS
AI
AI21
ANSI
API
AST
AWS
BTP
CD
CDN
CI
CLI
CMD
CORS
DEBUG
EKS
ERROR
FAQ
GLM
GNOME
GPT
HTML
HTTP
HTTPS
IAM
ID
IDE
INFO
IO
IP
IRSA
JS
JSON
JSONC
K2
LLM
LM
LSP
M2
MCP
MR
NET
NPM
NTLM
OIDC
OS
PAT
PATH
PHP
PR
PTY
README
RFC
RPC
SAP
SDK
SKILL
SSE
SSO
TS
TTY
TUI
UI
URL
US
UX
VCS
VPC
VPN
VS
WARN
WSL
X11
YAML
```
## Code identifiers used in prose (CamelCase, mixedCase)
```text
apiKey
AppleScript
AssistantMessage
baseURL
BurntSushi
ChatGPT
ClangFormat
CodeCompanion
CodeNomad
DeepSeek
DefaultV2
FileContent
FileDiff
FileNode
fineGrained
FormatterStatus
GitHub
GitLab
iTerm2
JavaScript
JetBrains
macOS
mDNS
MiniMax
NeuralNomadsAI
NickvanDyke
NoeFabris
OpenAI
OpenAPI
OpenChamber
OpenCode
OpenRouter
OpenTUI
OpenWork
ownUserPermissions
PowerShell
ProviderAuthAuthorization
ProviderAuthMethod
ProviderInitError
SessionStatus
TabItem
tokenType
ToolIDs
ToolList
TypeScript
typesUrl
UserMessage
VcsInfo
WebView2
WezTerm
xAI
ZenMux
```
## OpenCode CLI commands (as shown in docs)
```text
opencode
opencode [project]
opencode /path/to/project
opencode acp
opencode agent [command]
opencode agent create
opencode agent list
opencode attach [url]
opencode attach http://10.20.30.40:4096
opencode attach http://localhost:4096
opencode auth [command]
opencode auth list
opencode auth login
opencode auth logout
opencode auth ls
opencode export [sessionID]
opencode github [command]
opencode github install
opencode github run
opencode import <file>
opencode import https://opncd.ai/s/abc123
opencode import session.json
opencode mcp [command]
opencode mcp add
opencode mcp auth [name]
opencode mcp auth list
opencode mcp auth ls
opencode mcp auth my-oauth-server
opencode mcp auth sentry
opencode mcp debug <name>
opencode mcp debug my-oauth-server
opencode mcp list
opencode mcp logout [name]
opencode mcp logout my-oauth-server
opencode mcp ls
opencode models --refresh
opencode models [provider]
opencode models anthropic
opencode run [message..]
opencode run Explain the use of context in Go
opencode serve
opencode serve --cors http://localhost:5173 --cors https://app.example.com
opencode serve --hostname 0.0.0.0 --port 4096
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode session [command]
opencode session list
opencode stats
opencode uninstall
opencode upgrade
opencode upgrade [target]
opencode upgrade v0.1.48
opencode web
opencode web --cors https://example.com
opencode web --hostname 0.0.0.0
opencode web --mdns
opencode web --mdns --mdns-domain myproject.local
opencode web --port 4096
opencode web --port 4096 --hostname 0.0.0.0
opencode.server.close()
```
## Slash commands and routes
```text
/agent
/auth/:id
/clear
/command
/config
/config/providers
/connect
/continue
/doc
/editor
/event
/experimental/tool?provider=<p>&model=<m>
/experimental/tool/ids
/export
/file?path=<path>
/file/content?path=<p>
/file/status
/find?pattern=<pat>
/find/file
/find/file?query=<q>
/find/symbol?query=<q>
/formatter
/global/event
/global/health
/help
/init
/instance/dispose
/log
/lsp
/mcp
/mnt/
/mnt/c/
/mnt/d/
/models
/oc
/opencode
/path
/project
/project/current
/provider
/provider/{id}/oauth/authorize
/provider/{id}/oauth/callback
/provider/auth
/q
/quit
/redo
/resume
/session
/session/:id
/session/:id/abort
/session/:id/children
/session/:id/command
/session/:id/diff
/session/:id/fork
/session/:id/init
/session/:id/message
/session/:id/message/:messageID
/session/:id/permissions/:permissionID
/session/:id/prompt_async
/session/:id/revert
/session/:id/share
/session/:id/shell
/session/:id/summarize
/session/:id/todo
/session/:id/unrevert
/session/status
/share
/summarize
/theme
/tui
/tui/append-prompt
/tui/clear-prompt
/tui/control/next
/tui/control/response
/tui/execute-command
/tui/open-help
/tui/open-models
/tui/open-sessions
/tui/open-themes
/tui/show-toast
/tui/submit-prompt
/undo
/Users/username
/Users/username/projects/*
/vcs
```
## CLI flags and short options
```text
--agent
--attach
--command
--continue
--cors
--cwd
--days
--dir
--dry-run
--event
--file
--force
--fork
--format
--help
--hostname
--hostname 0.0.0.0
--keep-config
--keep-data
--log-level
--max-count
--mdns
--mdns-domain
--method
--model
--models
--port
--print-logs
--project
--prompt
--refresh
--session
--share
--title
--token
--tools
--verbose
--version
--wait
-c
-d
-f
-h
-m
-n
-s
-v
```
## Environment variables
```text
AI_API_URL
AI_FLOW_CONTEXT
AI_FLOW_EVENT
AI_FLOW_INPUT
AICORE_DEPLOYMENT_ID
AICORE_RESOURCE_GROUP
AICORE_SERVICE_KEY
ANTHROPIC_API_KEY
AWS_ACCESS_KEY_ID
AWS_BEARER_TOKEN_BEDROCK
AWS_PROFILE
AWS_REGION
AWS_ROLE_ARN
AWS_SECRET_ACCESS_KEY
AWS_WEB_IDENTITY_TOKEN_FILE
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
AZURE_RESOURCE_NAME
CI_PROJECT_DIR
CI_SERVER_FQDN
CI_WORKLOAD_REF
CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_GATEWAY_ID
CONTEXT7_API_KEY
GITHUB_TOKEN
GITLAB_AI_GATEWAY_URL
GITLAB_HOST
GITLAB_INSTANCE_URL
GITLAB_OAUTH_CLIENT_ID
GITLAB_TOKEN
GITLAB_TOKEN_OPENCODE
GOOGLE_APPLICATION_CREDENTIALS
GOOGLE_CLOUD_PROJECT
HTTP_PROXY
HTTPS_PROXY
K2_
MY_API_KEY
MY_ENV_VAR
MY_MCP_CLIENT_ID
MY_MCP_CLIENT_SECRET
NO_PROXY
NODE_ENV
NODE_EXTRA_CA_CERTS
NPM_AUTH_TOKEN
OC_ALLOW_WAYLAND
OPENCODE_API_KEY
OPENCODE_AUTH_JSON
OPENCODE_AUTO_SHARE
OPENCODE_CLIENT
OPENCODE_CONFIG
OPENCODE_CONFIG_CONTENT
OPENCODE_CONFIG_DIR
OPENCODE_DISABLE_AUTOCOMPACT
OPENCODE_DISABLE_AUTOUPDATE
OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS
OPENCODE_DISABLE_FILETIME_CHECK
OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_PRUNE
OPENCODE_DISABLE_TERMINAL_TITLE
OPENCODE_ENABLE_EXA
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
OPENCODE_EXPERIMENTAL
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
OPENCODE_EXPERIMENTAL_EXA
OPENCODE_EXPERIMENTAL_FILEWATCHER
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
OPENCODE_EXPERIMENTAL_LSP_TOOL
OPENCODE_EXPERIMENTAL_LSP_TY
OPENCODE_EXPERIMENTAL_MARKDOWN
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
OPENCODE_EXPERIMENTAL_OXFMT
OPENCODE_EXPERIMENTAL_PLAN_MODE
OPENCODE_FAKE_VCS
OPENCODE_GIT_BASH_PATH
OPENCODE_MODEL
OPENCODE_MODELS_URL
OPENCODE_PERMISSION
OPENCODE_PORT
OPENCODE_SERVER_PASSWORD
OPENCODE_SERVER_USERNAME
PROJECT_ROOT
RESOURCE_NAME
RUST_LOG
VARIABLE_NAME
VERTEX_LOCATION
XDG_CONFIG_HOME
```
## Package/module identifiers
```text
../../../config.mjs
@astrojs/starlight/components
@opencode-ai/plugin
@opencode-ai/sdk
path
shescape
zod
@
@ai-sdk/anthropic
@ai-sdk/cerebras
@ai-sdk/google
@ai-sdk/openai
@ai-sdk/openai-compatible
@File#L37-42
@modelcontextprotocol/server-everything
@opencode
```
## GitHub owner/repo slugs referenced in docs
```text
24601/opencode-zellij-namer
angristan/opencode-wakatime
anomalyco/opencode
apps/opencode-agent
athal7/opencode-devcontainers
awesome-opencode/awesome-opencode
backnotprop/plannotator
ben-vargas/ai-sdk-provider-opencode-sdk
btriapitsyn/openchamber
BurntSushi/ripgrep
Cluster444/agentic
code-yeongyu/oh-my-opencode
darrenhinde/opencode-agents
different-ai/opencode-scheduler
different-ai/openwork
features/copilot
folke/tokyonight.nvim
franlol/opencode-md-table-formatter
ggml-org/llama.cpp
ghoulr/opencode-websearch-cited.git
H2Shami/opencode-helicone-session
hosenur/portal
jamesmurdza/daytona
jenslys/opencode-gemini-auth
JRedeker/opencode-morph-fast-apply
JRedeker/opencode-shell-strategy
kdcokenny/ocx
kdcokenny/opencode-background-agents
kdcokenny/opencode-notify
kdcokenny/opencode-workspace
kdcokenny/opencode-worktree
login/device
mohak34/opencode-notifier
morhetz/gruvbox
mtymek/opencode-obsidian
NeuralNomadsAI/CodeNomad
nick-vi/opencode-type-inject
NickvanDyke/opencode.nvim
NoeFabris/opencode-antigravity-auth
nordtheme/nord
numman-ali/opencode-openai-codex-auth
olimorris/codecompanion.nvim
panta82/opencode-notificator
rebelot/kanagawa.nvim
remorses/kimaki
sainnhe/everforest
shekohex/opencode-google-antigravity-auth
shekohex/opencode-pty.git
spoons-and-mirrors/subtask2
sudo-tee/opencode.nvim
supermemoryai/opencode-supermemory
Tarquinen/opencode-dynamic-context-pruning
Th3Whit3Wolf/one-nvim
upstash/context7
vtemian/micode
vtemian/octto
yetone/avante.nvim
zenobi-us/opencode-plugin-template
zenobi-us/opencode-skillful
```
## Paths, filenames, globs, and URLs
```text
./.opencode/themes/*.json
./<project-slug>/storage/
./config/#custom-directory
./global/storage/
.agents/skills/*/SKILL.md
.agents/skills/<name>/SKILL.md
.clang-format
.claude
.claude/skills
.claude/skills/*/SKILL.md
.claude/skills/<name>/SKILL.md
.env
.github/workflows/opencode.yml
.gitignore
.gitlab-ci.yml
.ignore
.NET SDK
.npmrc
.ocamlformat
.opencode
.opencode/
.opencode/agents/
.opencode/commands/
.opencode/commands/test.md
.opencode/modes/
.opencode/plans/*.md
.opencode/plugins/
.opencode/skills/<name>/SKILL.md
.opencode/skills/git-release/SKILL.md
.opencode/tools/
.well-known/opencode
{ type: "raw" \| "patch", content: string }
{file:path/to/file}
**/*.js
%USERPROFILE%/intelephense/license.txt
%USERPROFILE%\.cache\opencode
%USERPROFILE%\.config\opencode\opencode.jsonc
%USERPROFILE%\.config\opencode\plugins
%USERPROFILE%\.local\share\opencode
%USERPROFILE%\.local\share\opencode\log
<project-root>/.opencode/themes/*.json
<providerId>/<modelId>
<your-project>/.opencode/plugins/
~
~/...
~/.agents/skills/*/SKILL.md
~/.agents/skills/<name>/SKILL.md
~/.aws/credentials
~/.bashrc
~/.cache/opencode
~/.cache/opencode/node_modules/
~/.claude/CLAUDE.md
~/.claude/skills/
~/.claude/skills/*/SKILL.md
~/.claude/skills/<name>/SKILL.md
~/.config/opencode
~/.config/opencode/AGENTS.md
~/.config/opencode/agents/
~/.config/opencode/commands/
~/.config/opencode/modes/
~/.config/opencode/opencode.json
~/.config/opencode/opencode.jsonc
~/.config/opencode/plugins/
~/.config/opencode/skills/*/SKILL.md
~/.config/opencode/skills/<name>/SKILL.md
~/.config/opencode/themes/*.json
~/.config/opencode/tools/
~/.config/zed/settings.json
~/.local/share
~/.local/share/opencode/
~/.local/share/opencode/auth.json
~/.local/share/opencode/log/
~/.local/share/opencode/mcp-auth.json
~/.local/share/opencode/opencode.jsonc
~/.npmrc
~/.zshrc
~/code/
~/Library/Application Support
~/projects/*
~/projects/personal/
${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts
$HOME/intelephense/license.txt
$HOME/projects/*
$XDG_CONFIG_HOME/opencode/themes/*.json
agent/
agents/
build/
commands/
dist/
http://<wsl-ip>:4096
http://127.0.0.1:8080/callback
http://localhost:<port>
http://localhost:4096
http://localhost:4096/doc
https://app.example.com
https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/
https://opencode.ai/zen/v1/chat/completions
https://opencode.ai/zen/v1/messages
https://opencode.ai/zen/v1/models/gemini-3-flash
https://opencode.ai/zen/v1/models/gemini-3-pro
https://opencode.ai/zen/v1/responses
https://RESOURCE_NAME.openai.azure.com/
laravel/pint
log/
model: "anthropic/claude-sonnet-4-5"
modes/
node_modules/
openai/gpt-4.1
opencode.ai/config.json
opencode/<model-id>
opencode/gpt-5.1-codex
opencode/gpt-5.2-codex
opencode/kimi-k2
openrouter/google/gemini-2.5-flash
opncd.ai/s/<share-id>
packages/*/AGENTS.md
plugins/
project/
provider_id/model_id
provider/model
provider/model-id
rm -rf ~/.cache/opencode
skills/
skills/*/SKILL.md
src/**/*.ts
themes/
tools/
```
## Keybind strings
```text
alt+b
Alt+Ctrl+K
alt+d
alt+f
Cmd+Esc
Cmd+Option+K
Cmd+Shift+Esc
Cmd+Shift+G
Cmd+Shift+P
ctrl+a
ctrl+b
ctrl+d
ctrl+e
Ctrl+Esc
ctrl+f
ctrl+g
ctrl+k
Ctrl+Shift+Esc
Ctrl+Shift+P
ctrl+t
ctrl+u
ctrl+w
ctrl+x
DELETE
Shift+Enter
WIN+R
```
## Model ID strings referenced
```text
{env:OPENCODE_MODEL}
anthropic/claude-3-5-sonnet-20241022
anthropic/claude-haiku-4-20250514
anthropic/claude-haiku-4-5
anthropic/claude-sonnet-4-20250514
anthropic/claude-sonnet-4-5
gitlab/duo-chat-haiku-4-5
lmstudio/google/gemma-3n-e4b
openai/gpt-4.1
openai/gpt-5
opencode/gpt-5.1-codex
opencode/gpt-5.2-codex
opencode/kimi-k2
openrouter/google/gemini-2.5-flash
```

View file

@ -1,4 +1,4 @@
Use this tool to assign and/or label a Github issue. Use this tool to assign and/or label a GitHub issue.
You can assign the following users: You can assign the following users:
- thdxr - thdxr

View file

@ -258,3 +258,49 @@ These are not strictly enforced, they are just general guidelines:
## Feature Requests ## Feature Requests
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly. For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Trust & Vouch System
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
### How it works
- **Vouched users** are explicitly trusted contributors.
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
### For maintainers
Collaborators with write access can manage the vouch list by commenting on any issue:
- `vouch` — vouch for the issue author
- `vouch @username` — vouch for a specific user
- `denounce` — denounce the issue author
- `denounce @username` — denounce a specific user
- `denounce @username <reason>` — denounce with a reason
- `unvouch` / `unvouch @username` — remove someone from the list
Changes are committed automatically to `.github/VOUCHED.td`.
### Denouncement policy
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
## Issue Requirements
All issues **must** use one of our issue templates:
- **Bug report** — for reporting bugs (requires a description)
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
- **Question** — for asking questions (requires the question)
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
Issues may be flagged for:
- Not using a template
- Required fields left empty or filled with placeholder text
- AI-generated walls of text
- Missing meaningful content
If you believe your issue was incorrectly flagged, let a maintainer know.

125
bun.lock
View file

@ -190,6 +190,7 @@
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "~2",
"@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-http": "~2",
@ -469,7 +470,7 @@
"@astrojs/solid-js": "5.1.0", "@astrojs/solid-js": "5.1.0",
"@astrojs/starlight": "0.34.3", "@astrojs/starlight": "0.34.3",
"@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/ibm-plex-mono": "5.2.5",
"@shikijs/transformers": "3.4.2", "@shikijs/transformers": "3.20.0",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"ai": "catalog:", "ai": "catalog:",
"astro": "5.7.13", "astro": "5.7.13",
@ -484,8 +485,10 @@
"shiki": "catalog:", "shiki": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"toolbeam-docs-theme": "0.4.8", "toolbeam-docs-theme": "0.4.8",
"vscode-languageserver-types": "3.17.5",
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "0.9.6",
"@types/node": "catalog:", "@types/node": "catalog:",
"opencode": "workspace:*", "opencode": "workspace:*",
"typescript": "catalog:", "typescript": "catalog:",
@ -616,12 +619,16 @@
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
"@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="],
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
"@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.1", "", {}, "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ=="], "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.1", "", {}, "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ=="],
"@astrojs/language-server": ["@astrojs/language-server@2.16.3", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/yaml2ts": "^0.2.2", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.27", "@volar/language-core": "~2.4.27", "@volar/language-server": "~2.4.27", "@volar/language-service": "~2.4.27", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.68", "volar-service-emmet": "0.0.68", "volar-service-html": "0.0.68", "volar-service-prettier": "0.0.68", "volar-service-typescript": "0.0.68", "volar-service-typescript-twoslash-queries": "0.0.68", "volar-service-yaml": "0.0.68", "vscode-html-languageservice": "^5.6.1", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-yO5K7RYCMXUfeDlnU6UnmtnoXzpuQc0yhlaCNZ67k1C/MiwwwvMZz+LGa+H35c49w5QBfvtr4w4Zcf5PcH8uYA=="],
"@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.2.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", "remark-smartypants": "^3.0.2", "shiki": "^3.0.0", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-c5F5gGrkczUaTVgmMW9g1YMJGzOtRvjjhw6IfGuxarM6ct09MpwysP10US729dy07gg8y+ofVifezvP3BNsWZg=="], "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.2.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", "remark-smartypants": "^3.0.2", "shiki": "^3.0.0", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-c5F5gGrkczUaTVgmMW9g1YMJGzOtRvjjhw6IfGuxarM6ct09MpwysP10US729dy07gg8y+ofVifezvP3BNsWZg=="],
"@astrojs/mdx": ["@astrojs/mdx@4.3.13", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.10", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.15.0", "es-module-lexer": "^1.7.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q=="], "@astrojs/mdx": ["@astrojs/mdx@4.3.13", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.10", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.15.0", "es-module-lexer": "^1.7.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q=="],
@ -638,6 +645,8 @@
"@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@1.0.0", "", {}, "sha512-qZxHwVnmb5FXuvRsaIGaqWgnftjCuMY+GSbaVZdBmE4j8AfgPqKPxYp8SUERyJcjpKCEmO4wD6ybuGH8A2kVRQ=="], "@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@1.0.0", "", {}, "sha512-qZxHwVnmb5FXuvRsaIGaqWgnftjCuMY+GSbaVZdBmE4j8AfgPqKPxYp8SUERyJcjpKCEmO4wD6ybuGH8A2kVRQ=="],
"@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.2", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
@ -848,6 +857,20 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],
"@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="],
"@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="],
"@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="],
"@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="],
"@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@ -1784,6 +1807,8 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="],
"@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="], "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="],
@ -1958,6 +1983,22 @@
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
"@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="],
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
"@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="],
"@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="],
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
"@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="], "@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="],
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
@ -1986,6 +2027,8 @@
"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.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-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@ -2388,6 +2431,8 @@
"electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="],
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
@ -3166,6 +3211,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
@ -3318,6 +3365,8 @@
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@ -3518,6 +3567,10 @@
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="], "remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
"request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
@ -3860,8 +3913,12 @@
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
"typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="], "ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="],
@ -3958,10 +4015,40 @@
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
"volar-service-css": ["volar-service-css@0.0.68", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q=="],
"volar-service-emmet": ["volar-service-emmet@0.0.68", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ=="],
"volar-service-html": ["volar-service-html@0.0.68", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA=="],
"volar-service-prettier": ["volar-service-prettier@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw=="],
"volar-service-typescript": ["volar-service-typescript@0.0.68", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw=="],
"volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w=="],
"volar-service-yaml": ["volar-service-yaml@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.19.2" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA=="],
"vscode-css-languageservice": ["vscode-css-languageservice@6.3.9", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-1tLWfp+TDM5ZuVWht3jmaY5y7O6aZmpeXLoHl5bv1QtRsRKt4xYGRMmdJa5Pqx/FTkgRbsna9R+Gn2xE+evVuA=="],
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.1", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-5Mrqy5CLfFZUgkyhNZLA1Ye5g12Cb/v6VM7SxUzZUaRKWMDz4md+y26PrfRTSU0/eQAl3XpO9m2og+GGtDMuaA=="],
"vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
@ -4022,6 +4109,8 @@
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yaml-language-server": ["yaml-language-server@1.19.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
@ -4094,6 +4183,8 @@
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
@ -4288,7 +4379,7 @@
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
@ -4362,6 +4453,8 @@
"@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "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.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
@ -4588,6 +4681,8 @@
"vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
@ -4598,6 +4693,12 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"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=="],
"yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
"yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@ -4618,6 +4719,10 @@
"@ai-sdk/openai/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "@ai-sdk/openai/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@astrojs/check/yargs/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=="],
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.5", "", {}, "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.5", "", {}, "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA=="],
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
@ -4862,9 +4967,9 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@ -5108,6 +5213,14 @@
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
"@astrojs/check/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@astrojs/check/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"@astrojs/check/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"@astrojs/check/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
@ -5248,6 +5361,10 @@
"tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="], "@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="],
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

View file

@ -30,6 +30,26 @@
}; };
}); });
overlays = {
default =
final: _prev:
let
node_modules = final.callPackage ./nix/node_modules.nix {
inherit rev;
};
opencode = final.callPackage ./nix/opencode.nix {
inherit node_modules;
};
desktop = final.callPackage ./nix/desktop.nix {
inherit opencode;
};
in
{
inherit opencode;
opencode-desktop = desktop;
};
};
packages = forEachSystem ( packages = forEachSystem (
pkgs: pkgs:
let let

View file

@ -275,7 +275,7 @@ async function assertOpencodeConnected() {
body: { body: {
service: "github-workflow", service: "github-workflow",
level: "info", level: "info",
message: "Prepare to react to Github Workflow event", message: "Prepare to react to GitHub Workflow event",
}, },
}) })
connected = true connected = true

View file

@ -135,6 +135,16 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS8"), new sst.Secret("ZEN_MODELS8"),
new sst.Secret("ZEN_MODELS9"), new sst.Secret("ZEN_MODELS9"),
new sst.Secret("ZEN_MODELS10"), new sst.Secret("ZEN_MODELS10"),
new sst.Secret("ZEN_MODELS11"),
new sst.Secret("ZEN_MODELS12"),
new sst.Secret("ZEN_MODELS13"),
new sst.Secret("ZEN_MODELS14"),
new sst.Secret("ZEN_MODELS15"),
new sst.Secret("ZEN_MODELS16"),
new sst.Secret("ZEN_MODELS17"),
new sst.Secret("ZEN_MODELS18"),
new sst.Secret("ZEN_MODELS19"),
new sst.Secret("ZEN_MODELS20"),
] ]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=", "x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=",
"aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=", "aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=",
"aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=", "aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=",
"x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q=" "x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4="
} }
} }

View file

@ -30,7 +30,7 @@ stdenvNoCC.mkDerivation {
../bun.lock ../bun.lock
../package.json ../package.json
../patches ../patches
../install ../install # required by desktop build (cli.rs include_str!)
] ]
); );
}; };

View file

@ -34,6 +34,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
''; '';
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
env.OPENCODE_DISABLE_MODELS_FETCH = true;
env.OPENCODE_VERSION = finalAttrs.version; env.OPENCODE_VERSION = finalAttrs.version;
env.OPENCODE_CHANNEL = "local"; env.OPENCODE_CHANNEL = "local";
@ -79,7 +80,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook writableTmpDirAsHomeHook
]; ];
doInstallCheck = true; doInstallCheck = true;
versionCheckKeepEnvironment = [ "HOME" ]; versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ];
versionCheckProgramArg = "--version"; versionCheckProgramArg = "--version";
passthru = { passthru = {

View file

@ -1,27 +1,32 @@
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises" import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
import { join, relative } from "path" import { join, relative } from "path"
type SemverLike = {
valid: (value: string) => string | null
rcompare: (left: string, right: string) => number
}
type Entry = { type Entry = {
dir: string dir: string
version: string version: string
label: string
} }
async function isDirectory(path: string) {
try {
const info = await lstat(path)
return info.isDirectory()
} catch {
return false
}
}
const isValidSemver = (v: string) => Bun.semver.satisfies(v, "x.x.x")
const root = process.cwd() const root = process.cwd()
const bunRoot = join(root, "node_modules/.bun") const bunRoot = join(root, "node_modules/.bun")
const linkRoot = join(bunRoot, "node_modules") const linkRoot = join(bunRoot, "node_modules")
const directories = (await readdir(bunRoot)).sort() const directories = (await readdir(bunRoot)).sort()
const versions = new Map<string, Entry[]>() const versions = new Map<string, Entry[]>()
for (const entry of directories) { for (const entry of directories) {
const full = join(bunRoot, entry) const full = join(bunRoot, entry)
const info = await lstat(full) if (!(await isDirectory(full))) {
if (!info.isDirectory()) {
continue continue
} }
const parsed = parseEntry(entry) const parsed = parseEntry(entry)
@ -29,37 +34,23 @@ for (const entry of directories) {
continue continue
} }
const list = versions.get(parsed.name) ?? [] const list = versions.get(parsed.name) ?? []
list.push({ dir: full, version: parsed.version, label: entry }) list.push({ dir: full, version: parsed.version })
versions.set(parsed.name, list) versions.set(parsed.name, list)
} }
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
| SemverLike
| {
default: SemverLike
}
const semver = "default" in semverModule ? semverModule.default : semverModule
const selections = new Map<string, Entry>() const selections = new Map<string, Entry>()
for (const [slug, list] of versions) { for (const [slug, list] of versions) {
list.sort((a, b) => { list.sort((a, b) => {
const left = semver.valid(a.version) const aValid = isValidSemver(a.version)
const right = semver.valid(b.version) const bValid = isValidSemver(b.version)
if (left && right) { if (aValid && bValid) return -Bun.semver.order(a.version, b.version)
const delta = semver.rcompare(left, right) if (aValid) return -1
if (delta !== 0) { if (bValid) return 1
return delta
}
}
if (left && !right) {
return -1
}
if (!left && right) {
return 1
}
return b.version.localeCompare(a.version) return b.version.localeCompare(a.version)
}) })
selections.set(slug, list[0]) const first = list[0]
if (first) selections.set(slug, first)
} }
await rm(linkRoot, { recursive: true, force: true }) await rm(linkRoot, { recursive: true, force: true })
@ -77,10 +68,7 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
await mkdir(parent, { recursive: true }) await mkdir(parent, { recursive: true })
const linkPath = join(parent, leaf) const linkPath = join(parent, leaf)
const desired = join(entry.dir, "node_modules", slug) const desired = join(entry.dir, "node_modules", slug)
const exists = await lstat(desired) if (!(await isDirectory(desired))) {
.then((info) => info.isDirectory())
.catch(() => false)
if (!exists) {
continue continue
} }
const relativeTarget = relative(parent, desired) const relativeTarget = relative(parent, desired)

View file

@ -8,7 +8,7 @@ type PackageManifest = {
const root = process.cwd() const root = process.cwd()
const bunRoot = join(root, "node_modules/.bun") const bunRoot = join(root, "node_modules/.bun")
const bunEntries = (await safeReadDir(bunRoot)).sort() const bunEntries = (await readdir(bunRoot)).sort()
let rewritten = 0 let rewritten = 0
for (const entry of bunEntries) { for (const entry of bunEntries) {
@ -45,11 +45,11 @@ for (const entry of bunEntries) {
} }
} }
console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`) console.log(`[normalize-bun-binaries] rebuilt ${rewritten} links`)
async function collectPackages(modulesRoot: string) { async function collectPackages(modulesRoot: string) {
const found: string[] = [] const found: string[] = []
const topLevel = (await safeReadDir(modulesRoot)).sort() const topLevel = (await readdir(modulesRoot)).sort()
for (const name of topLevel) { for (const name of topLevel) {
if (name === ".bin" || name === ".bun") { if (name === ".bin" || name === ".bun") {
continue continue
@ -59,7 +59,7 @@ async function collectPackages(modulesRoot: string) {
continue continue
} }
if (name.startsWith("@")) { if (name.startsWith("@")) {
const scoped = (await safeReadDir(full)).sort() const scoped = (await readdir(full)).sort()
for (const child of scoped) { for (const child of scoped) {
const scopedDir = join(full, child) const scopedDir = join(full, child)
if (await isDirectory(scopedDir)) { if (await isDirectory(scopedDir)) {
@ -121,14 +121,6 @@ async function isDirectory(path: string) {
} }
} }
async function safeReadDir(path: string) {
try {
return await readdir(path)
} catch {
return []
}
}
function normalizeBinName(name: string) { function normalizeBinName(name: string) {
const slash = name.lastIndexOf("/") const slash = name.lastIndexOf("/")
if (slash >= 0) { if (slash >= 0) {

View file

@ -1,2 +1,3 @@
[test] [test]
root = "./src"
preload = ["./happydom.ts"] preload = ["./happydom.ts"]

View file

@ -0,0 +1,140 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (slug === root) return ""
if (seen.includes(slug)) return ""
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
const slug = slugFromUrl(page.url())
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await page.keyboard.type(text)
await page.keyboard.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return sessionID
}
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return ""
return info.directory
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root }) => {
const workspaces = [] as { slug: string; directory: string }[]
const sessions = [] as string[]
try {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
workspaces.push(first)
await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
workspaces.push(second)
await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
sessions.push(firstSession)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
sessions.push(secondSession)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
sessions.push(thirdSession)
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
} finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all(
sessions.map((sessionID) =>
Promise.all(
dirs.map((dir) =>
createSdk(dir)
.session.delete({ sessionID })
.catch(() => undefined),
),
),
),
)
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
}
})
})

View file

@ -48,6 +48,9 @@ export const workspaceItemSelector = (slug: string) =>
export const workspaceMenuTriggerSelector = (slug: string) => export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
export const workspaceNewSessionSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
export const listItemSelector = '[data-slot="list-item"]' export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`

View file

@ -0,0 +1,126 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { createSdk, modKey } from "../utils"
import { promptSelector } from "../selectors"
async function seedConversation(input: {
page: Page
sdk: ReturnType<typeof createSdk>
sessionID: string
token: string
}) {
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
await input.page.keyboard.press("Enter")
let userMessageID: string | undefined
await expect
.poll(
async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 50 })
.then((r) => r.data ?? [])
const users = messages.filter((m) => m.info.role === "user")
if (users.length === 0) return false
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
userMessageID = user.info.id
const assistantText = messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
return assistantText.includes(input.token)
},
{ timeout: 90_000 },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
return { prompt, userMessageID }
}
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
})
})
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
})
})
})

View file

@ -1,6 +1,6 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSettings, closeDialog, withSession } from "../actions" import { openSettings, closeDialog, withSession } from "../actions"
import { keybindButtonSelector } from "../selectors" import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
@ -267,11 +267,14 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await closeDialog(page, dialog) await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+Y`) const terminal = page.locator(terminalSelector)
await page.waitForTimeout(100) await expect(terminal).not.toBeVisible()
const pageStable = await page.evaluate(() => document.readyState === "complete") await page.keyboard.press(`${modKey}+Y`)
expect(pageStable).toBe(true) await expect(terminal).toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()
}) })
test("changing command palette keybind works", async ({ page, gotoSession }) => { test("changing command palette keybind works", async ({ page, gotoSession }) => {

View file

@ -14,7 +14,7 @@ export default defineConfig({
expect: { expect: {
timeout: 10_000, timeout: 10_000,
}, },
fullyParallel: true, fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],

View file

@ -55,6 +55,7 @@ const extraArgs = (() => {
const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
const serverEnv = { const serverEnv = {
...process.env, ...process.env,
@ -83,58 +84,95 @@ const runnerEnv = {
PLAYWRIGHT_PORT: String(webPort), PLAYWRIGHT_PORT: String(webPort),
} satisfies Record<string, string> } satisfies Record<string, string>
const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { let seed: ReturnType<typeof Bun.spawn> | undefined
cwd: opencodeDir, let runner: ReturnType<typeof Bun.spawn> | undefined
env: serverEnv, let server: { stop: () => Promise<void> | void } | undefined
stdout: "inherit", let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
stderr: "inherit", let cleaned = false
})
const seedExit = await seed.exited const cleanup = async () => {
if (seedExit !== 0) { if (cleaned) return
process.exit(seedExit) cleaned = true
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
const jobs = [
inst?.Instance.disposeAll(),
server?.stop(),
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean)
await Promise.allSettled(jobs)
} }
Object.assign(process.env, serverEnv) const shutdown = (code: number, reason: string) => {
process.env.AGENT = "1" process.exitCode = code
process.env.OPENCODE = "1" void cleanup().finally(() => {
console.error(`e2e-local shutdown: ${reason}`)
process.exit(code)
})
}
const log = await import("../../opencode/src/util/log") const reportInternalError = (reason: string, error: unknown) => {
const install = await import("../../opencode/src/installation") console.warn(`e2e-local ignored server error: ${reason}`)
await log.Log.init({ console.warn(error)
print: true, }
dev: install.Installation.isLocal(),
level: "WARN", process.once("SIGINT", () => shutdown(130, "SIGINT"))
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
process.once("uncaughtException", (error) => {
reportInternalError("uncaughtException", error)
})
process.once("unhandledRejection", (error) => {
reportInternalError("unhandledRejection", error)
}) })
const servermod = await import("../../opencode/src/server/server") let code = 1
const inst = await import("../../opencode/src/project/instance")
const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) try {
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
stderr: "inherit",
})
const seedExit = await seed.exited
if (seedExit !== 0) {
code = seedExit
} else {
Object.assign(process.env, serverEnv)
process.env.AGENT = "1"
process.env.OPENCODE = "1"
const log = await import("../../opencode/src/util/log")
const install = await import("../../opencode/src/installation")
await log.Log.init({
print: true,
dev: install.Installation.isLocal(),
level: "WARN",
})
const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance")
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
const result = await (async () => {
try {
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
cwd: appDir, cwd: appDir,
env: runnerEnv, env: runnerEnv,
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
}) })
code = await runner.exited
return { code: await runner.exited }
} catch (error) {
return { error }
} finally {
await inst.Instance.disposeAll()
await server.stop()
} }
})() } catch (error) {
console.error(error)
if ("error" in result) { code = 1
console.error(result.error) } finally {
process.exit(1) await cleanup()
} }
process.exit(result.code) process.exit(code)

View file

@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
) )
} }
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) { export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform() const platform = usePlatform()
const stored = (() => { const stored = (() => {
@ -106,7 +106,7 @@ export function AppInterface(props: { defaultUrl?: string; children?: JSX.Elemen
} }
return ( return (
<ServerProvider defaultUrl={defaultServerUrl()}> <ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>

View file

@ -6,6 +6,7 @@ let dirsToExpand: typeof import("./file-tree").dirsToExpand
beforeAll(async () => { beforeAll(async () => {
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}), useParams: () => ({}),
})) }))
mock.module("@/context/file", () => ({ mock.module("@/context/file", () => ({

View file

@ -1,4 +1,5 @@
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import { Collapsible } from "@opencode-ai/ui/collapsible" import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@ -20,11 +21,7 @@ import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@opencode-ai/sdk/v2"
function pathToFileUrl(filepath: string): string { function pathToFileUrl(filepath: string): string {
const encodedPath = filepath return `file://${encodeFilePath(filepath)}`
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
return `file://${encodedPath}`
} }
type Kind = "add" | "del" | "mix" type Kind = "add" | "del" | "mix"
@ -223,12 +220,14 @@ export default function FileTree(props: {
seen.add(item) seen.add(item)
} }
return out.toSorted((a, b) => { out.sort((a, b) => {
if (a.type !== b.type) { if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1 return a.type === "directory" ? -1 : 1
} }
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
return out
}) })
const Node = ( const Node = (

View file

@ -35,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
@ -97,6 +98,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const command = useCommand() const command = useCommand()
const permission = usePermission() const permission = usePermission()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
@ -205,7 +207,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
historyIndex: number historyIndex: number
savedPrompt: Prompt | null savedPrompt: Prompt | null
placeholder: number placeholder: number
dragging: boolean draggingType: "image" | "@mention" | null
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
}>({ }>({
@ -213,7 +215,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
historyIndex: -1, historyIndex: -1,
savedPrompt: null, savedPrompt: null,
placeholder: Math.floor(Math.random() * EXAMPLES.length), placeholder: Math.floor(Math.random() * EXAMPLES.length),
dragging: false, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
}) })
@ -413,7 +415,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} = useFilteredList<SlashCommand>({ } = useFilteredList<SlashCommand>({
items: slashCommands, items: slashCommands,
key: (x) => x?.id, key: (x) => x?.id,
filterKeys: ["trigger", "title", "description"], filterKeys: ["trigger", "title"],
onSelect: handleSlashSelect, onSelect: handleSlashSelect,
}) })
@ -760,8 +762,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editor: () => editorRef, editor: () => editorRef,
isFocused, isFocused,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDragging: (value) => setStore("dragging", value), setDraggingType: (type) => setStore("draggingType", type),
focusEditor: () => {
editorRef.focus()
setCursorPosition(editorRef, promptLength(prompt.current()))
},
addPart, addPart,
readClipboardImage: platform.readClipboardImage,
}) })
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
@ -780,7 +787,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
setMode: (mode) => setStore("mode", mode), setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover), setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: props.newSessionWorktree, newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
}) })
@ -946,11 +953,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"group/prompt-input": true, "group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true, "rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.dragging, "border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
> >
<PromptDragOverlay dragging={store.dragging} label={language.t("prompt.dropzone.label")} /> <PromptDragOverlay
type={store.draggingType}
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
/>
<PromptContextItems <PromptContextItems
items={prompt.context.items()} items={prompt.context.items()}
active={(item) => { active={(item) => {
@ -983,6 +993,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-multiline="true" aria-multiline="true"
aria-label={placeholder()} aria-label={placeholder()}
contenteditable="true" contenteditable="true"
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={() => setComposing(true)} onCompositionStart={() => setComposing(true)}

View file

@ -11,8 +11,10 @@ type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isFocused: () => boolean isFocused: () => boolean
isDialogActive: () => boolean isDialogActive: () => boolean
setDragging: (value: boolean) => void setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => void addPart: (part: ContentPart) => void
readClipboardImage?: () => Promise<File | null>
} }
export function createPromptAttachments(input: PromptAttachmentsInput) { export function createPromptAttachments(input: PromptAttachmentsInput) {
@ -29,7 +31,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dataUrl = reader.result as string const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = { const attachment: ImageAttachmentPart = {
type: "image", type: "image",
id: crypto.randomUUID(), id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
filename: file.name, filename: file.name,
mime: file.type, mime: file.type,
dataUrl, dataUrl,
@ -75,6 +77,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
const plainText = clipboardData.getData("text/plain") ?? "" const plainText = clipboardData.getData("text/plain") ?? ""
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file) {
await addImageAttachment(file)
return
}
}
if (!plainText) return if (!plainText) return
input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
@ -84,15 +96,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
event.preventDefault() event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files") const hasFiles = event.dataTransfer?.types.includes("Files")
const hasText = event.dataTransfer?.types.includes("text/plain")
if (hasFiles) { if (hasFiles) {
input.setDragging(true) input.setDraggingType("image")
} else if (hasText) {
input.setDraggingType("@mention")
} }
} }
const handleGlobalDragLeave = (event: DragEvent) => { const handleGlobalDragLeave = (event: DragEvent) => {
if (input.isDialogActive()) return if (input.isDialogActive()) return
if (!event.relatedTarget) { if (!event.relatedTarget) {
input.setDragging(false) input.setDraggingType(null)
} }
} }
@ -100,7 +115,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (input.isDialogActive()) return if (input.isDialogActive()) return
event.preventDefault() event.preventDefault()
input.setDragging(false) input.setDraggingType(null)
const plainText = event.dataTransfer?.getData("text/plain")
const filePrefix = "file:"
if (plainText?.startsWith(filePrefix)) {
const filePath = plainText.slice(filePrefix.length)
input.focusEditor()
input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 })
return
}
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return

View file

@ -64,4 +64,214 @@ describe("buildRequestParts", () => {
expect(fooFiles).toHaveLength(2) expect(fooFiles).toHaveLength(2)
expect(synthetic).toHaveLength(1) expect(synthetic).toHaveLength(1)
}) })
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
messageID: "msg_win_1",
sessionID: "ses_win_1",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
// Should create valid file URLs
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(filePart.url).not.toContain("%5C")
// Should have normalized to forward slashes
expect(filePart.url).toContain("/src/foo.ts")
}
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@file#name.txt",
messageID: "msg_win_2",
sessionID: "ses_win_2",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
}
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src/app.ts",
messageID: "msg_linux_1",
sessionID: "ses_linux_1",
sessionDirectory: "/home/user/project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
}
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@README.md",
messageID: "msg_mac_1",
sessionID: "ses_mac_1",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
}
})
test("handles context files with Windows paths", () => {
const prompt: Prompt = []
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
messageID: "msg_win_ctx",
sessionID: "ses_win_ctx",
sessionDirectory: "D:\\workspace\\app",
})
const fileParts = result.requestParts.filter((part) => part.type === "file")
expect(fileParts).toHaveLength(2)
// All file URLs should be valid
fileParts.forEach((part) => {
if (part.type === "file") {
expect(() => new URL(part.url)).not.toThrow()
expect(part.url).not.toContain("%5C") // No encoded backslashes
}
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
messageID: "msg_abs",
sessionID: "ses_abs",
sessionDirectory: "C:\\current\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D:/other/project/file.ts")
}
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
messageID: "msg_sel",
sessionID: "ses_sel",
sessionDirectory: "C:\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should have query parameters
expect(filePart.url).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Query params should parse correctly
const url = new URL(filePart.url)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
}
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
messageID: "msg_dots",
sessionID: "ses_dots",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(filePart.url).toContain("/..")
}
})
}) })

View file

@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
@ -27,14 +28,12 @@ type BuildRequestPartsInput = {
sessionDirectory: string sessionDirectory: string
} }
const absolute = (directory: string, path: string) => const absolute = (directory: string, path: string) => {
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
const encodeFilePath = (filepath: string): string => if (path.startsWith("\\\\") || path.startsWith("//")) return path
filepath return `${directory.replace(/[\\/]+$/, "")}/${path}`
.split("/") }
.map((segment) => encodeURIComponent(segment))
.join("/")
const fileQuery = (selection: FileSelection | undefined) => const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""

View file

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

View file

@ -0,0 +1,175 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
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}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
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")
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
mock.module("@opencode-ai/ui/toast", () => ({
showToast: () => 0,
}))
mock.module("@opencode-ai/util/encode", () => ({
base64Encode: (value: string) => value,
}))
mock.module("@/context/local", () => ({
useLocal: () => ({
model: {
current: () => ({ id: "model", provider: { id: "provider" } }),
variant: { current: () => undefined },
},
agent: {
current: () => ({ name: "agent" }),
},
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
items: () => [],
},
}),
}))
mock.module("@/context/layout", () => ({
useLayout: () => ({
handoff: {
setTabs: () => undefined,
},
}),
}))
mock.module("@/context/sdk", () => ({
useSDK: () => ({
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
}),
}))
mock.module("@/context/sync", () => ({
useSync: () => ({
data: { command: [] },
session: {
optimistic: {
add: () => undefined,
remove: () => undefined,
},
},
set: () => undefined,
}),
}))
mock.module("@/context/global-sync", () => ({
useGlobalSync: () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
return [{}, () => undefined]
},
}),
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({
fetch: fetch,
}),
}))
mock.module("@/context/language", () => ({
useLanguage: () => ({
t: (key: string) => key,
}),
}))
const mod = await import("./submit")
createPromptSubmit = mod.createPromptSubmit
})
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
})
describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
})
})

View file

@ -37,7 +37,7 @@ type PromptSubmitInput = {
resetHistoryNavigation: () => void resetHistoryNavigation: () => void
setMode: (mode: "normal" | "shell") => void setMode: (mode: "normal" | "shell") => void
setPopover: (popover: "at" | "slash" | null) => void setPopover: (popover: "at" | "slash" | null) => void
newSessionWorktree?: string newSessionWorktree?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void onNewSessionWorktreeReset?: () => void
onSubmit?: () => void onSubmit?: () => void
} }
@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk.directory const projectDirectory = sdk.directory
const isNewSession = !params.id const isNewSession = !params.id
const worktreeSelection = input.newSessionWorktree ?? "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
let client = sdk.client let client = sdk.client
@ -200,7 +200,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
} }
if (!session) return if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
return
}
input.onSubmit?.() input.onSubmit?.()

View file

@ -79,15 +79,16 @@ describe("getSessionContextMetrics", () => {
expect(metrics.context?.usage).toBeNull() expect(metrics.context?.usage).toBeNull()
}) })
test("memoizes by message and provider array identity", () => { test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }] const providers = [{ id: "openai", models: {} }]
const one = getSessionContextMetrics(messages, providers) const one = getSessionContextMetrics(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContextMetrics(messages, providers) const two = getSessionContextMetrics(messages, providers)
const three = getSessionContextMetrics([...messages], providers)
expect(two).toBe(one) expect(one.context?.message.id).toBe("a1")
expect(three).not.toBe(one) expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
}) })
}) })

View file

@ -34,8 +34,6 @@ type Metrics = {
context: Context | undefined context: Context | undefined
} }
const cache = new WeakMap<Message[], WeakMap<Provider[], Metrics>>()
const tokenTotal = (msg: AssistantMessage) => { const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
} }
@ -80,15 +78,5 @@ const build = (messages: Message[], providers: Provider[]): Metrics => {
} }
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) { export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
const byProvider = cache.get(messages) return build(messages, providers)
if (byProvider) {
const hit = byProvider.get(providers)
if (hit) return hit
}
const value = build(messages, providers)
const next = byProvider ?? new WeakMap<Provider[], Metrics>()
next.set(providers, value)
if (!byProvider) cache.set(messages, next)
return value
} }

View file

@ -112,21 +112,35 @@ export function SessionHeader() {
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true }) const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
const apps = createMemo(() => {
if (os() === "macos") return MAC_APPS
if (os() === "windows") return WINDOWS_APPS
return LINUX_APPS
})
const fileManager = createMemo(() => {
if (os() === "macos") return { label: "Finder", icon: "finder" as const }
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
return { label: "File Manager", icon: "finder" as const }
})
createEffect(() => { createEffect(() => {
if (platform.platform !== "desktop") return if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return if (!platform.checkAppExists) return
const list = os() const list = apps()
const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : []
if (apps.length === 0) return setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all( void Promise.all(
apps.map((app) => list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => { Promise.resolve(platform.checkAppExists?.(app.openWith))
const ok = Boolean(value) .then((value) => Boolean(value))
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`) .catch(() => false)
return [app.id, ok] as const .then((ok) => {
}), console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
return [app.id, ok] as const
}),
), ),
).then((entries) => { ).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>) setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
@ -134,23 +148,23 @@ export function SessionHeader() {
}) })
const options = createMemo(() => { const options = createMemo(() => {
if (os() === "macos") {
return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const
}
if (os() === "windows") {
return [
{ id: "finder", label: "File Explorer", icon: "file-explorer" },
...WINDOWS_APPS.filter((app) => exists[app.id]),
] as const
}
return [ return [
{ id: "finder", label: "File Manager", icon: "finder" }, { id: "finder", label: fileManager().label, icon: fileManager().icon },
...LINUX_APPS.filter((app) => exists[app.id]), ...apps().filter((app) => exists[app.id]),
] as const ] as const
}) })
type OpenIcon = OpenApp | "file-explorer"
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
const list = apps()
return list.every((app) => exists[app.id] !== undefined)
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
@ -158,6 +172,7 @@ export function SessionHeader() {
createEffect(() => { createEffect(() => {
if (platform.platform !== "desktop") return if (platform.platform !== "desktop") return
if (!checksReady()) return
const value = prefs.app const value = prefs.app
if (options().some((o) => o.id === value)) return if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder") setPrefs("app", options()[0]?.id ?? "finder")
@ -283,7 +298,7 @@ export function SessionHeader() {
<Portal mount={mount()}> <Portal mount={mount()}>
<button <button
type="button" type="button"
class="hidden md:flex w-[320px] max-w-full min-w-0 p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active" class="hidden md:flex w-[320px] max-w-full min-w-0 h-[24px] px-2 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")} onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")} aria-label={language.t("session.header.searchFiles")}
> >
@ -294,7 +309,11 @@ export function SessionHeader() {
</span> </span>
</div> </div>
<Show when={hotkey()}>{(keybind) => <Keybind class="shrink-0">{keybind()}</Keybind>}</Show> <Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
)}
</Show>
</button> </button>
</Portal> </Portal>
)} )}
@ -303,6 +322,7 @@ export function SessionHeader() {
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<StatusPopover />
<Show when={projectDirectory()}> <Show when={projectDirectory()}>
<div class="hidden xl:flex items-center"> <div class="hidden xl:flex items-center">
<Show <Show
@ -322,62 +342,68 @@ export function SessionHeader() {
} }
> >
<div class="flex items-center"> <div class="flex items-center">
<Button <div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
variant="ghost" <Button
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none rounded-r-none"
onClick={() => openDir(current().id)}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<AppIcon id={current().icon} class="size-5" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.action", { app: current().label })}
</span>
</Button>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost" variant="ghost"
class="rounded-sm h-[24px] w-auto px-1.5 border-none shadow-none rounded-l-none data-[expanded]:bg-surface-raised-base-active" class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
aria-label={language.t("session.header.open.menu")} onClick={() => openDir(current().id)}
/> aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
<DropdownMenu.Portal> >
<DropdownMenu.Content placement="bottom-end" gutter={6}> <div class="flex size-5 shrink-0 items-center justify-center">
<DropdownMenu.Group> <AppIcon id={current().icon} class="size-4" />
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel> </div>
<DropdownMenu.RadioGroup <span class="text-12-regular text-text-strong">Open</span>
value={prefs.app} </Button>
onChange={(value) => { <div class="self-stretch w-px bg-border-base/70" />
if (!OPEN_APPS.includes(value as OpenApp)) return <DropdownMenu gutter={6} placement="bottom-end">
setPrefs("app", value as OpenApp) <DropdownMenu.Trigger
}} as={IconButton}
> icon="chevron-down"
{options().map((o) => ( variant="ghost"
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}> class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
<AppIcon id={o.icon} class="size-5" /> aria-label={language.t("session.header.open.menu")}
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> />
<DropdownMenu.ItemIndicator> <DropdownMenu.Portal>
<Icon name="check-small" size="small" class="text-icon-weak" /> <DropdownMenu.Content>
</DropdownMenu.ItemIndicator> <DropdownMenu.Group>
</DropdownMenu.RadioItem> <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
))} <DropdownMenu.RadioGroup
</DropdownMenu.RadioGroup> value={prefs.app}
</DropdownMenu.Group> onChange={(value) => {
<DropdownMenu.Separator /> if (!OPEN_APPS.includes(value as OpenApp)) return
<DropdownMenu.Item onSelect={copyPath}> setPrefs("app", value as OpenApp)
<Icon name="copy" size="small" class="text-icon-weak" /> }}
<DropdownMenu.ItemLabel> >
{language.t("session.header.open.copyPath")} {options().map((o) => (
</DropdownMenu.ItemLabel> <DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
</DropdownMenu.Item> <div class="flex size-5 shrink-0 items-center justify-center">
</DropdownMenu.Content> <AppIcon id={o.icon} class={size(o.icon)} />
</DropdownMenu.Portal> </div>
</DropdownMenu> <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={copyPath}>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div> </div>
</Show> </Show>
</div> </div>
</Show> </Show>
<StatusPopover />
<Show when={showShare()}> <Show when={showShare()}>
<div class="flex items-center"> <div class="flex items-center">
<Popover <Popover
@ -393,8 +419,9 @@ export function SessionHeader() {
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden" class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
triggerAs={Button} triggerAs={Button}
triggerProps={{ triggerProps={{
variant: "secondary", variant: "ghost",
class: "rounded-sm h-[24px] px-3", class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
classList: { "rounded-r-none": shareUrl() !== undefined }, classList: { "rounded-r-none": shareUrl() !== undefined },
style: { scale: 1 }, style: { scale: 1 },
}} }}
@ -420,7 +447,14 @@ export function SessionHeader() {
} }
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<TextField value={shareUrl() ?? ""} readOnly copyable tabIndex={-1} class="w-full" /> <TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<Button <Button
size="large" size="large"
@ -459,8 +493,8 @@ export function SessionHeader() {
> >
<IconButton <IconButton
icon={state.copied ? "check" : "link"} icon={state.copied ? "check" : "link"}
variant="secondary" variant="ghost"
class="rounded-l-none" class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={copyLink} onClick={copyLink}
disabled={state.unshare} disabled={state.unshare}
aria-label={ aria-label={

View file

@ -1,6 +1,7 @@
import { Show, createMemo } from "solid-js" import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
@ -15,6 +16,7 @@ interface NewSessionViewProps {
export function NewSessionView(props: NewSessionViewProps) { export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
@ -24,11 +26,11 @@ export function NewSessionView(props: NewSessionViewProps) {
if (options().includes(selection)) return selection if (options().includes(selection)) return selection
return MAIN_WORKTREE return MAIN_WORKTREE
}) })
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory) const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const isWorktree = createMemo(() => { const isWorktree = createMemo(() => {
const project = sync.project const project = sync.project
if (!project) return false if (!project) return false
return sync.data.path.directory !== project.worktree return sdk.directory !== project.worktree
}) })
const label = (value: string) => { const label = (value: string) => {
@ -45,7 +47,7 @@ export function NewSessionView(props: NewSessionViewProps) {
} }
return ( return (
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"> <div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div> <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-center gap-3"> <div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" /> <Icon name="folder" size="small" />

View file

@ -1,8 +1,10 @@
import { Component, createMemo, type JSX } from "solid-js" import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@ -40,6 +42,8 @@ export const SettingsGeneral: Component = () => {
checking: false, checking: false,
}) })
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const check = () => { const check = () => {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
setStore("checking", true) setStore("checking", true)
@ -410,13 +414,49 @@ export const SettingsGeneral: Component = () => {
</SettingsRow> </SettingsRow>
</div> </div>
</div> </div>
<Show when={linux()}>
{(_) => {
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
const onChange = (checked: boolean) =>
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={value() === "wayland"} onChange={onChange} />
</div>
</SettingsRow>
</div>
</div>
)
}}
</Show>
</div> </div>
</div> </div>
) )
} }
interface SettingsRowProps { interface SettingsRowProps {
title: string title: string | JSX.Element
description: string | JSX.Element description: string | JSX.Element
children: JSX.Element children: JSX.Element
} }

View file

@ -141,7 +141,7 @@ export function StatusPopover() {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: class:
"rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none data-[expanded]:bg-surface-raised-base-active", "rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={ trigger={

View file

@ -3,6 +3,7 @@ import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitPr
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { monoFontFamily, useSettings } from "@/context/settings" import { monoFontFamily, useSettings } from "@/context/settings"
import { parseKeybind, matchKeybind } from "@/context/command"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal" import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
@ -10,6 +11,8 @@ import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
onSubmit?: () => void onSubmit?: () => void
@ -71,7 +74,9 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaBlur: () => void let handleTextareaBlur: () => void
let disposed = false let disposed = false
const cleanups: VoidFunction[] = [] const cleanups: VoidFunction[] = []
let tail = local.pty.tail ?? "" const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
const cleanup = () => { const cleanup = () => {
if (!cleanups.length) return if (!cleanups.length) return
@ -161,13 +166,16 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false } const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
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:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) { if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode" url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword url.password = window.__OPENCODE__?.serverPassword
} }
const socket = new WebSocket(url) const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
cleanups.push(() => { cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
}) })
@ -237,12 +245,11 @@ export const Terminal = (props: TerminalProps) => {
return true return true
} }
// allow for ctrl-` to toggle terminal in parent // allow for toggle terminal keybinds in parent
if (event.ctrlKey && key === "`") { const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND
return true const keybinds = parseKeybind(config)
}
return false return matchKeybind(keybinds, event)
}) })
const fit = new mod.FitAddon() const fit = new mod.FitAddon()
@ -287,26 +294,6 @@ export const Terminal = (props: TerminalProps) => {
handleResize = () => fit.fit() handleResize = () => fit.fit()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize)) cleanups.push(() => window.removeEventListener("resize", handleResize))
const limit = 16_384
const min = 32
const windowMs = 750
const seed = tail.length > limit ? tail.slice(-limit) : tail
let sync = seed.length >= min
let syncUntil = 0
const stopSync = () => {
sync = false
syncUntil = 0
}
const overlap = (data: string) => {
if (!seed) return 0
const max = Math.min(seed.length, data.length)
if (max < min) return 0
for (let i = max; i >= min; i--) {
if (seed.slice(-i) === data.slice(0, i)) return i
}
return 0
}
const onResize = t.onResize(async (size) => { const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
@ -323,7 +310,6 @@ export const Terminal = (props: TerminalProps) => {
}) })
cleanups.push(() => disposeIfDisposable(onResize)) cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => { const onData = t.onData((data) => {
if (data) stopSync()
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
socket.send(data) socket.send(data)
} }
@ -341,7 +327,6 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => { const handleOpen = () => {
local.onConnect?.() local.onConnect?.()
if (sync) syncUntil = Date.now() + windowMs
sdk.client.pty sdk.client.pty
.update({ .update({
ptyID: local.pty.id, ptyID: local.pty.id,
@ -355,31 +340,31 @@ export const Terminal = (props: TerminalProps) => {
socket.addEventListener("open", handleOpen) socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen)) cleanups.push(() => socket.removeEventListener("open", handleOpen))
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (disposed) return if (disposed) return
if (event.data instanceof ArrayBuffer) {
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch {
// ignore
}
return
}
const data = typeof event.data === "string" ? event.data : "" const data = typeof event.data === "string" ? event.data : ""
if (!data) return if (!data) return
t.write(data)
const next = (() => { cursor += data.length
if (!sync) return data
if (syncUntil && Date.now() > syncUntil) {
stopSync()
return data
}
const n = overlap(data)
if (!n) {
stopSync()
return data
}
const trimmed = data.slice(n)
if (trimmed) stopSync()
return trimmed
})()
if (!next) return
t.write(next)
tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
} }
socket.addEventListener("message", handleMessage) socket.addEventListener("message", handleMessage)
cleanups.push(() => socket.removeEventListener("message", handleMessage)) cleanups.push(() => socket.removeEventListener("message", handleMessage))
@ -433,7 +418,7 @@ export const Terminal = (props: TerminalProps) => {
props.onCleanup({ props.onCleanup({
...local.pty, ...local.pty,
buffer, buffer,
tail, cursor,
rows: t.rows, rows: t.rows,
cols: t.cols, cols: t.cols,
scrollY: t.getViewportY(), scrollY: t.getViewportY(),

View file

@ -68,12 +68,14 @@ export function Titlebar() {
id: "common.goBack", id: "common.goBack",
title: language.t("common.goBack"), title: language.t("common.goBack"),
category: language.t("command.category.view"), category: language.t("command.category.view"),
keybind: "mod+[",
onSelect: back, onSelect: back,
}, },
{ {
id: "common.goForward", id: "common.goForward",
title: language.t("common.goForward"), title: language.t("common.goForward"),
category: language.t("command.category.view"), category: language.t("command.category.view"),
keybind: "mod+]",
onSelect: forward, onSelect: forward,
}, },
]) ])

View file

@ -6,6 +6,7 @@ let createCommentSessionForTest: typeof import("./comments").createCommentSessio
beforeAll(async () => { beforeAll(async () => {
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}), useParams: () => ({}),
})) }))
mock.module("@opencode-ai/ui/context", () => ({ mock.module("@opencode-ai/ui/context", () => ({

View file

@ -53,7 +53,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
const add = (input: Omit<LineComment, "id" | "time">) => { const add = (input: Omit<LineComment, "id" | "time">) => {
const next: LineComment = { const next: LineComment = {
id: crypto.randomUUID(), id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
time: Date.now(), time: Date.now(),
...input, ...input,
} }

View file

@ -7,6 +7,7 @@ import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { createPathHelpers } from "./file/path" import { createPathHelpers } from "./file/path"
import { import {
approxBytes, approxBytes,
@ -50,9 +51,11 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
useSync() useSync()
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
const layout = useLayout()
const scope = createMemo(() => sdk.directory) const scope = createMemo(() => sdk.directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@ -183,6 +186,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
invalidateFromWatcher(e.details, { invalidateFromWatcher(e.details, {
normalize: path.normalize, normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]), hasFile: (file) => Boolean(store.file[file]),
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
loadFile: (file) => { loadFile: (file) => {
void load(file, { force: true }) void load(file, { force: true })
}, },

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path" import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path"
describe("file path helpers", () => { describe("file path helpers", () => {
test("normalizes file inputs against workspace root", () => { test("normalizes file inputs against workspace root", () => {
@ -25,3 +25,328 @@ describe("file path helpers", () => {
expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
}) })
}) })
describe("encodeFilePath", () => {
describe("Linux/Unix paths", () => {
test("should handle Linux absolute path", () => {
const linuxPath = "/home/user/project/README.md"
const result = encodeFilePath(linuxPath)
const fileUrl = `file://${result}`
// Should create a valid URL
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/home/user/project/README.md")
const url = new URL(fileUrl)
expect(url.protocol).toBe("file:")
expect(url.pathname).toBe("/home/user/project/README.md")
})
test("should handle Linux path with special characters", () => {
const linuxPath = "/home/user/file#name with spaces.txt"
const result = encodeFilePath(linuxPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/home/user/file%23name%20with%20spaces.txt")
})
test("should handle Linux relative path", () => {
const relativePath = "src/components/App.tsx"
const result = encodeFilePath(relativePath)
expect(result).toBe("src/components/App.tsx")
})
test("should handle Linux root directory", () => {
const result = encodeFilePath("/")
expect(result).toBe("/")
})
test("should handle Linux path with all special chars", () => {
const path = "/path/to/file#with?special%chars&more.txt"
const result = encodeFilePath(path)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toContain("%23") // #
expect(result).toContain("%3F") // ?
expect(result).toContain("%25") // %
expect(result).toContain("%26") // &
})
})
describe("macOS paths", () => {
test("should handle macOS absolute path", () => {
const macPath = "/Users/kelvin/Projects/opencode/README.md"
const result = encodeFilePath(macPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/Users/kelvin/Projects/opencode/README.md")
})
test("should handle macOS path with spaces", () => {
const macPath = "/Users/kelvin/My Documents/file.txt"
const result = encodeFilePath(macPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toContain("My%20Documents")
})
})
describe("Windows paths", () => {
test("should handle Windows absolute path with backslashes", () => {
const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
const result = encodeFilePath(windowsPath)
const fileUrl = `file://${result}`
// Should create a valid, parseable URL
expect(() => new URL(fileUrl)).not.toThrow()
const url = new URL(fileUrl)
expect(url.protocol).toBe("file:")
expect(url.pathname).toContain("README.bs.md")
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
})
test("should handle mixed separator path (Windows + Unix)", () => {
// This is what happens in build-request-parts.ts when concatenating paths
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
const result = encodeFilePath(mixedPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
})
test("should handle Windows path with spaces", () => {
const windowsPath = "C:\\Program Files\\MyApp\\file with spaces.txt"
const result = encodeFilePath(windowsPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toContain("Program%20Files")
expect(result).toContain("file%20with%20spaces.txt")
})
test("should handle Windows path with special chars in filename", () => {
const windowsPath = "D:\\projects\\file#name with ?marks.txt"
const result = encodeFilePath(windowsPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toContain("file%23name%20with%20%3Fmarks.txt")
})
test("should handle Windows root directory", () => {
const windowsPath = "C:\\"
const result = encodeFilePath(windowsPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C:/")
})
test("should handle Windows relative path with backslashes", () => {
const windowsPath = "src\\components\\App.tsx"
const result = encodeFilePath(windowsPath)
// Relative paths shouldn't get the leading slash
expect(result).toBe("src/components/App.tsx")
})
test("should NOT create invalid URL like the bug report", () => {
// This is the exact scenario from bug report by @alexyaroshuk
const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
const result = encodeFilePath(windowsPath)
const fileUrl = `file://${result}`
// The bug was creating: file://D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md
expect(result).not.toContain("%5C") // Should not have encoded backslashes
expect(result).not.toBe("D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md")
// Should be valid
expect(() => new URL(fileUrl)).not.toThrow()
})
test("should handle lowercase drive letters", () => {
const windowsPath = "c:\\users\\test\\file.txt"
const result = encodeFilePath(windowsPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/c:/users/test/file.txt")
})
})
describe("Cross-platform compatibility", () => {
test("should preserve Unix paths unchanged (except encoding)", () => {
const unixPath = "/usr/local/bin/app"
const result = encodeFilePath(unixPath)
expect(result).toBe("/usr/local/bin/app")
})
test("should normalize Windows paths for cross-platform use", () => {
const windowsPath = "C:\\Users\\test\\file.txt"
const result = encodeFilePath(windowsPath)
// Should convert to forward slashes and add leading /
expect(result).not.toContain("\\")
expect(result).toMatch(/^\/[A-Za-z]:\//)
})
test("should handle relative paths the same on all platforms", () => {
const unixRelative = "src/app.ts"
const windowsRelative = "src\\app.ts"
const unixResult = encodeFilePath(unixRelative)
const windowsResult = encodeFilePath(windowsRelative)
// Both should normalize to forward slashes
expect(unixResult).toBe("src/app.ts")
expect(windowsResult).toBe("src/app.ts")
})
})
describe("Edge cases", () => {
test("should handle empty path", () => {
const result = encodeFilePath("")
expect(result).toBe("")
})
test("should handle path with multiple consecutive slashes", () => {
const result = encodeFilePath("//path//to///file.txt")
// Multiple slashes should be preserved (backend handles normalization)
expect(result).toBe("//path//to///file.txt")
})
test("should encode Unicode characters", () => {
const unicodePath = "/home/user/文档/README.md"
const result = encodeFilePath(unicodePath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
// Unicode should be encoded
expect(result).toContain("%E6%96%87%E6%A1%A3")
})
test("should handle already normalized Windows path", () => {
// Path that's already been normalized (has / before drive letter)
const alreadyNormalized = "/D:/path/file.txt"
const result = encodeFilePath(alreadyNormalized)
// Should not add another leading slash
expect(result).toBe("/D:/path/file.txt")
expect(result).not.toContain("//D")
})
test("should handle just drive letter", () => {
const justDrive = "D:"
const result = encodeFilePath(justDrive)
const fileUrl = `file://${result}`
expect(result).toBe("/D:")
expect(() => new URL(fileUrl)).not.toThrow()
})
test("should handle Windows path with trailing backslash", () => {
const trailingBackslash = "C:\\Users\\test\\"
const result = encodeFilePath(trailingBackslash)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C:/Users/test/")
})
test("should handle very long paths", () => {
const longPath = "C:\\Users\\test\\" + "verylongdirectoryname\\".repeat(20) + "file.txt"
const result = encodeFilePath(longPath)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).not.toContain("\\")
})
test("should handle paths with dots", () => {
const pathWithDots = "C:\\Users\\..\\test\\.\\file.txt"
const result = encodeFilePath(pathWithDots)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
// Dots should be preserved (backend normalizes)
expect(result).toContain("..")
expect(result).toContain("/./")
})
})
describe("Regression tests for PR #12424", () => {
test("should handle file with # in name", () => {
const path = "/path/to/file#name.txt"
const result = encodeFilePath(path)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/path/to/file%23name.txt")
})
test("should handle file with ? in name", () => {
const path = "/path/to/file?name.txt"
const result = encodeFilePath(path)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/path/to/file%3Fname.txt")
})
test("should handle file with % in name", () => {
const path = "/path/to/file%name.txt"
const result = encodeFilePath(path)
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/path/to/file%25name.txt")
})
})
describe("Integration with file:// URL construction", () => {
test("should work with query parameters (Linux)", () => {
const path = "/home/user/file.txt"
const encoded = encodeFilePath(path)
const fileUrl = `file://${encoded}?start=10&end=20`
const url = new URL(fileUrl)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
expect(url.pathname).toBe("/home/user/file.txt")
})
test("should work with query parameters (Windows)", () => {
const path = "C:\\Users\\test\\file.txt"
const encoded = encodeFilePath(path)
const fileUrl = `file://${encoded}?start=10&end=20`
const url = new URL(fileUrl)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
})
test("should parse correctly in URL constructor (Linux)", () => {
const path = "/var/log/app.log"
const fileUrl = `file://${encodeFilePath(path)}`
const url = new URL(fileUrl)
expect(url.protocol).toBe("file:")
expect(url.pathname).toBe("/var/log/app.log")
})
test("should parse correctly in URL constructor (Windows)", () => {
const path = "D:\\logs\\app.log"
const fileUrl = `file://${encodeFilePath(path)}`
const url = new URL(fileUrl)
expect(url.protocol).toBe("file:")
expect(url.pathname).toContain("app.log")
})
})
})

View file

@ -81,9 +81,23 @@ export function decodeFilePath(input: string) {
} }
export function encodeFilePath(filepath: string): string { export function encodeFilePath(filepath: string): string {
return filepath // Normalize Windows paths: convert backslashes to forward slashes
let normalized = filepath.replace(/\\/g, "/")
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
if (/^[A-Za-z]:/.test(normalized)) {
normalized = "/" + normalized
}
// Encode each path segment (preserving forward slashes as path separators)
// Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers
// can reliably detect drives.
return normalized
.split("/") .split("/")
.map((segment) => encodeURIComponent(segment)) .map((segment, index) => {
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
return encodeURIComponent(segment)
})
.join("/") .join("/")
} }

View file

@ -27,6 +27,37 @@ describe("file watcher invalidation", () => {
expect(refresh).toEqual(["src"]) expect(refresh).toEqual(["src"])
}) })
test("reloads files that are open in tabs", () => {
const loads: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
properties: {
file: "src/open.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
isOpen: (path) => path === "src/open.ts",
loadFile: (path) => loads.push(path),
node: () => ({
path: "src/open.ts",
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
},
)
expect(loads).toEqual(["src/open.ts"])
})
test("refreshes only changed loaded directory nodes", () => { test("refreshes only changed loaded directory nodes", () => {
const refresh: string[] = [] const refresh: string[] = []

View file

@ -8,6 +8,7 @@ type WatcherEvent = {
type WatcherOps = { type WatcherOps = {
normalize: (input: string) => string normalize: (input: string) => string
hasFile: (path: string) => boolean hasFile: (path: string) => boolean
isOpen?: (path: string) => boolean
loadFile: (path: string) => void loadFile: (path: string) => void
node: (path: string) => FileNode | undefined node: (path: string) => FileNode | undefined
isDirLoaded: (path: string) => boolean isDirLoaded: (path: string) => boolean
@ -27,7 +28,7 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (!path) return if (!path) return
if (path.startsWith(".git/")) return if (path.startsWith(".git/")) return
if (ops.hasFile(path)) { if (ops.hasFile(path) || ops.isOpen?.(path)) {
ops.loadFile(path) ops.loadFile(path)
} }

View file

@ -12,10 +12,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
// Prefer the WebView fetch implementation for streaming responses.
// @tauri-apps/plugin-http 2.5.x has known issues with streaming/cancellation that can
// retain native resources in the Rust process.
const eventSdk = createOpencodeClient({ const eventSdk = createOpencodeClient({
baseUrl: server.url, baseUrl: server.url,
signal: abort.signal, signal: abort.signal,
fetch: platform.fetch,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: Event

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client" import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { State } from "./types" import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
@ -34,6 +34,29 @@ const textPart = (id: string, sessionID: string, messageID: string) =>
text: id, text: id,
}) as Part }) as Part
const permissionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
permission: title,
patterns: ["*"],
metadata: {},
always: [],
}) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
questions: [
{
question: title,
header: title,
options: [{ label: title, description: title }],
},
],
}) as QuestionRequest
const baseState = (input: Partial<State> = {}) => const baseState = (input: Partial<State> = {}) =>
({ ({
status: "complete", status: "complete",
@ -164,6 +187,264 @@ describe("applyDirectoryEvent", () => {
expect(store.session_status.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined()
}) })
test("cleans session caches when deleted and decrements only root totals", () => {
const cases = [
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
]
for (const item of cases) {
const message = userMessage("msg_1", item.info.id)
const [store, setStore] = createStore(
baseState({
session: [
rootSession({ id: "ses_1" }),
rootSession({ id: "ses_2", parentID: "ses_1" }),
rootSession({ id: "ses_3" }),
],
sessionTotal: 2,
message: { [item.info.id]: [message] },
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
session_diff: { [item.info.id]: [] },
todo: { [item.info.id]: [] },
permission: { [item.info.id]: [] },
question: { [item.info.id]: [] },
session_status: { [item.info.id]: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.deleted", properties: { info: item.info } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined()
expect(store.sessionTotal).toBe(item.expectedTotal)
expect(store.message[item.info.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[item.info.id]).toBeUndefined()
expect(store.todo[item.info.id]).toBeUndefined()
expect(store.permission[item.info.id]).toBeUndefined()
expect(store.question[item.info.id]).toBeUndefined()
expect(store.session_status[item.info.id]).toBeUndefined()
}
})
test("upserts and removes messages while clearing orphaned parts", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
}),
)
applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
applyDirectoryEvent({
event: {
type: "message.updated",
properties: {
info: {
...userMessage("msg_2", sessionID),
role: "assistant",
} as Message,
},
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
expect(store.part.msg_2).toBeUndefined()
})
test("upserts and prunes message parts", () => {
const sessionID = "ses_1"
const messageID = "msg_1"
const [store, setStore] = createStore(
baseState({
part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] },
}),
)
applyDirectoryEvent({
event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"])
applyDirectoryEvent({
event: {
type: "message.part.updated",
properties: {
part: {
...textPart("prt_2", sessionID, messageID),
text: "changed",
} as Part,
},
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
const updated = store.part[messageID]?.find((x) => x.id === "prt_2")
expect(updated?.type).toBe("text")
if (updated?.type === "text") expect(updated.text).toBe("changed")
applyDirectoryEvent({
event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
applyDirectoryEvent({
event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
applyDirectoryEvent({
event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part[messageID]).toBeUndefined()
})
test("tracks permission and question request lifecycles", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] },
}),
)
applyDirectoryEvent({
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"])
applyDirectoryEvent({
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
applyDirectoryEvent({
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
applyDirectoryEvent({
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
})
test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState())
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
vcsCache: {
store: cacheStore,
setStore: setCacheStore,
ready: () => true,
},
})
expect(store.vcs).toEqual({ branch: "feature/test" })
expect(cacheStore.value).toEqual({ branch: "feature/test" })
})
test("routes disposal and lsp events to side-effect handlers", () => { test("routes disposal and lsp events to side-effect handlers", () => {
const [store, setStore] = createStore(baseState()) const [store, setStore] = createStore(baseState())
const pushes: string[] = [] const pushes: string[] = []

View file

@ -1,36 +1,44 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test, vi } from "bun:test"
import { createScrollPersistence } from "./layout-scroll" import { createScrollPersistence } from "./layout-scroll"
describe("createScrollPersistence", () => { describe("createScrollPersistence", () => {
test("debounces persisted scroll writes", async () => { test("debounces persisted scroll writes", () => {
const snapshot = { vi.useFakeTimers()
session: { try {
review: { x: 0, y: 0 }, const snapshot = {
}, session: {
} as Record<string, Record<string, { x: number; y: number }>> review: { x: 0, y: 0 },
const writes: Array<Record<string, { x: number; y: number }>> = [] },
const scroll = createScrollPersistence({ } as Record<string, Record<string, { x: number; y: number }>>
debounceMs: 10, const writes: Array<Record<string, { x: number; y: number }>> = []
getSnapshot: (sessionKey) => snapshot[sessionKey], const scroll = createScrollPersistence({
onFlush: (sessionKey, next) => { debounceMs: 10,
snapshot[sessionKey] = next getSnapshot: (sessionKey) => snapshot[sessionKey],
writes.push(next) onFlush: (sessionKey, next) => {
}, snapshot[sessionKey] = next
}) writes.push(next)
},
})
for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
scroll.setScroll("session", "review", { x: 0, y: i }) scroll.setScroll("session", "review", { x: 0, y: i })
}
vi.advanceTimersByTime(9)
expect(writes).toHaveLength(0)
vi.advanceTimersByTime(1)
expect(writes).toHaveLength(1)
expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
scroll.setScroll("session", "review", { x: 0, y: 30 })
vi.advanceTimersByTime(20)
expect(writes).toHaveLength(1)
scroll.dispose()
} finally {
vi.useRealTimers()
} }
await new Promise((resolve) => setTimeout(resolve, 40))
expect(writes).toHaveLength(1)
expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
scroll.setScroll("session", "review", { x: 0, y: 30 })
await new Promise((resolve) => setTimeout(resolve, 20))
expect(writes).toHaveLength(1)
scroll.dispose()
}) })
}) })

View file

@ -6,6 +6,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
export type ModelKey = { providerID: string; modelID: string } export type ModelKey = { providerID: string; modelID: string }
@ -184,11 +185,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
models.setVisibility(model, visible) models.setVisibility(model, visible)
}, },
variant: { variant: {
current() { configured() {
const a = agent.current()
const m = current()
if (!a || !m) return undefined
return getConfiguredAgentVariant({
agent: { model: a.model, variant: a.variant },
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
})
},
selected() {
const m = current() const m = current()
if (!m) return undefined if (!m) return undefined
return models.variant.get({ providerID: m.provider.id, modelID: m.id }) return models.variant.get({ providerID: m.provider.id, modelID: m.id })
}, },
current() {
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
},
list() { list() {
const m = current() const m = current()
if (!m) return [] if (!m) return []
@ -203,17 +220,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle() { cycle() {
const variants = this.list() const variants = this.list()
if (variants.length === 0) return if (variants.length === 0) return
const currentVariant = this.current() this.set(
if (!currentVariant) { cycleModelVariant({
this.set(variants[0]) variants,
return selected: this.selected(),
} configured: this.configured(),
const index = variants.indexOf(currentVariant) }),
if (index === -1 || index === variants.length - 1) { )
this.set(undefined)
return
}
this.set(variants[index + 1])
}, },
}, },
} }

View file

@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
describe("model variant", () => {
test("resolves configured agent variant when model matches", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "openai",
modelID: "gpt-5.2",
variants: { low: {}, high: {}, xhigh: {} },
},
})
expect(value).toBe("xhigh")
})
test("ignores configured variant when model does not match", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4",
variants: { low: {}, high: {}, xhigh: {} },
},
})
expect(value).toBeUndefined()
})
test("prefers selected variant over configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: "high",
configured: "xhigh",
})
expect(value).toBe("high")
})
test("cycles from configured variant to next", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "high",
})
expect(value).toBe("xhigh")
})
test("wraps from configured last variant to first", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBe("low")
})
})

View file

@ -0,0 +1,50 @@
type AgentModel = {
providerID: string
modelID: string
}
type Agent = {
model?: AgentModel
variant?: string
}
type Model = AgentModel & {
variants?: Record<string, unknown>
}
type VariantInput = {
variants: string[]
selected: string | undefined
configured: string | undefined
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
if (!input.agent?.variant) return undefined
if (!input.agent.model) return undefined
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
}
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
}

View file

@ -57,6 +57,12 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */ /** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
/** Set the preferred display backend (desktop only) */
setDisplayBackend?(backend: DisplayBackend): Promise<void>
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string> parseMarkdown?(markdown: string): Promise<string>
@ -65,8 +71,13 @@ export type Platform = {
/** Check if an editor app exists (desktop only) */ /** Check if an editor app exists (desktop only) */
checkAppExists?(appName: string): Promise<boolean> checkAppExists?(appName: string): Promise<boolean>
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>
} }
export type DisplayBackend = "auto" | "wayland"
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
name: "Platform", name: "Platform",
init: (props: { value: Platform }) => { init: (props: { value: Platform }) => {

View file

@ -28,13 +28,14 @@ function projectsKey(url: string) {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { defaultUrl: string }) => { init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), Persist.global("server", ["server.v3"]),
createStore({ createStore({
list: [] as string[], list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>, lastProject: {} as Record<string, string>,
}), }),
@ -59,7 +60,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const fallback = normalizeServerUrl(props.defaultUrl) const fallback = normalizeServerUrl(props.defaultUrl)
if (fallback && url === fallback) { if (fallback && url === fallback) {
setState("active", url) batch(() => {
if (!store.list.includes(url)) {
// Add the fallback url to the list if it's not already in the list
setStore("list", store.list.length, url)
}
setState("active", url)
})
return return
} }
@ -89,7 +96,20 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active) return if (state.active) return
const url = normalizeServerUrl(props.defaultUrl) const url = normalizeServerUrl(props.defaultUrl)
if (!url) return if (!url) return
setState("active", url) batch(() => {
// Remove the previous startup sidecar url
if (store.currentSidecarUrl) {
remove(store.currentSidecarUrl)
}
// Add the new sidecar url
if (props.isSidecar && props.defaultUrl) {
add(props.defaultUrl)
setStore("currentSidecarUrl", props.defaultUrl)
}
setState("active", url)
})
}) })
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)

View file

@ -5,6 +5,7 @@ let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => str
beforeAll(async () => { beforeAll(async () => {
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}), useParams: () => ({}),
})) }))
mock.module("@opencode-ai/ui/context", () => ({ mock.module("@opencode-ai/ui/context", () => ({

View file

@ -13,7 +13,7 @@ export type LocalPTY = {
cols?: number cols?: number
buffer?: string buffer?: string
scrollY?: number scrollY?: number
tail?: string cursor?: number
} }
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "جلسة جديدة", "command.session.new": "جلسة جديدة",
"command.file.open": "فتح ملف", "command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.context.addSelection": "إضافة التحديد إلى السياق", "command.context.addSelection": "إضافة التحديد إلى السياق",
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي", "command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
"command.input.focus": "التركيز على حقل الإدخال",
"command.terminal.toggle": "تبديل المحطة الطرفية", "command.terminal.toggle": "تبديل المحطة الطرفية",
"command.fileTree.toggle": "تبديل شجرة الملفات", "command.fileTree.toggle": "تبديل شجرة الملفات",
"command.review.toggle": "تبديل المراجعة", "command.review.toggle": "تبديل المراجعة",
@ -211,6 +213,7 @@ export const dict = {
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة", "prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
"prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
"prompt.slash.badge.custom": "مخصص", "prompt.slash.badge.custom": "مخصص",
"prompt.slash.badge.skill": "مهارة", "prompt.slash.badge.skill": "مهارة",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -232,6 +235,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "فشل إرسال أمر shell", "prompt.toast.shellSendFailed.title": "فشل إرسال أمر shell",
"prompt.toast.commandSendFailed.title": "فشل إرسال الأمر", "prompt.toast.commandSendFailed.title": "فشل إرسال الأمر",
"prompt.toast.promptSendFailed.title": "فشل إرسال الموجه", "prompt.toast.promptSendFailed.title": "فشل إرسال الموجه",
"prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل", "dialog.mcp.description": "{{enabled}} من {{total}} مفعل",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "Nova sessão", "command.session.new": "Nova sessão",
"command.file.open": "Abrir arquivo", "command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.context.addSelection": "Adicionar seleção ao contexto", "command.context.addSelection": "Adicionar seleção ao contexto",
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual", "command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
"command.input.focus": "Focar entrada",
"command.terminal.toggle": "Alternar terminal", "command.terminal.toggle": "Alternar terminal",
"command.fileTree.toggle": "Alternar árvore de arquivos", "command.fileTree.toggle": "Alternar árvore de arquivos",
"command.review.toggle": "Alternar revisão", "command.review.toggle": "Alternar revisão",
@ -211,6 +213,7 @@ export const dict = {
"prompt.popover.emptyResults": "Nenhum resultado correspondente", "prompt.popover.emptyResults": "Nenhum resultado correspondente",
"prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.popover.emptyCommands": "Nenhum comando correspondente",
"prompt.dropzone.label": "Solte imagens ou PDFs aqui", "prompt.dropzone.label": "Solte imagens ou PDFs aqui",
"prompt.dropzone.file.label": "Solte para @mencionar arquivo",
"prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -232,6 +235,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Falha ao enviar comando shell", "prompt.toast.shellSendFailed.title": "Falha ao enviar comando shell",
"prompt.toast.commandSendFailed.title": "Falha ao enviar comando", "prompt.toast.commandSendFailed.title": "Falha ao enviar comando",
"prompt.toast.promptSendFailed.title": "Falha ao enviar prompt", "prompt.toast.promptSendFailed.title": "Falha ao enviar prompt",
"prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados", "dialog.mcp.description": "{{enabled}} de {{total}} habilitados",

View file

@ -47,6 +47,7 @@ export const dict = {
"command.tab.close": "Zatvori karticu", "command.tab.close": "Zatvori karticu",
"command.context.addSelection": "Dodaj odabir u kontekst", "command.context.addSelection": "Dodaj odabir u kontekst",
"command.context.addSelection.description": "Dodaj odabrane linije iz trenutne datoteke", "command.context.addSelection.description": "Dodaj odabrane linije iz trenutne datoteke",
"command.input.focus": "Fokusiraj polje za unos",
"command.terminal.toggle": "Prikaži/sakrij terminal", "command.terminal.toggle": "Prikaži/sakrij terminal",
"command.fileTree.toggle": "Prikaži/sakrij stablo datoteka", "command.fileTree.toggle": "Prikaži/sakrij stablo datoteka",
"command.review.toggle": "Prikaži/sakrij pregled", "command.review.toggle": "Prikaži/sakrij pregled",
@ -219,6 +220,7 @@ export const dict = {
"prompt.popover.emptyResults": "Nema rezultata", "prompt.popover.emptyResults": "Nema rezultata",
"prompt.popover.emptyCommands": "Nema komandi", "prompt.popover.emptyCommands": "Nema komandi",
"prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje", "prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje",
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
"prompt.slash.badge.custom": "prilagođeno", "prompt.slash.badge.custom": "prilagođeno",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -240,6 +242,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Neuspješno slanje shell naredbe", "prompt.toast.shellSendFailed.title": "Neuspješno slanje shell naredbe",
"prompt.toast.commandSendFailed.title": "Neuspješno slanje komande", "prompt.toast.commandSendFailed.title": "Neuspješno slanje komande",
"prompt.toast.promptSendFailed.title": "Neuspješno slanje upita", "prompt.toast.promptSendFailed.title": "Neuspješno slanje upita",
"prompt.toast.promptSendFailed.description": "Nije moguće dohvatiti sesiju",
"dialog.mcp.title": "MCP-ovi", "dialog.mcp.title": "MCP-ovi",
"dialog.mcp.description": "{{enabled}} od {{total}} omogućeno", "dialog.mcp.description": "{{enabled}} od {{total}} omogućeno",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "Ny session", "command.session.new": "Ny session",
"command.file.open": "Åbn fil", "command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.context.addSelection": "Tilføj markering til kontekst", "command.context.addSelection": "Tilføj markering til kontekst",
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil", "command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
"command.input.focus": "Fokuser inputfelt",
"command.terminal.toggle": "Skift terminal", "command.terminal.toggle": "Skift terminal",
"command.fileTree.toggle": "Skift filtræ", "command.fileTree.toggle": "Skift filtræ",
"command.review.toggle": "Skift gennemgang", "command.review.toggle": "Skift gennemgang",
@ -211,6 +213,7 @@ export const dict = {
"prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyResults": "Ingen matchende resultater",
"prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slip billeder eller PDF'er her", "prompt.dropzone.label": "Slip billeder eller PDF'er her",
"prompt.dropzone.file.label": "Slip for at @nævne fil",
"prompt.slash.badge.custom": "brugerdefineret", "prompt.slash.badge.custom": "brugerdefineret",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -232,6 +235,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando", "prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando",
"prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando", "prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando",
"prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørgsel", "prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørgsel",
"prompt.toast.promptSendFailed.description": "Kunne ikke hente session",
"dialog.mcp.title": "MCP'er", "dialog.mcp.title": "MCP'er",
"dialog.mcp.description": "{{enabled}} af {{total}} aktiveret", "dialog.mcp.description": "{{enabled}} af {{total}} aktiveret",

View file

@ -48,8 +48,10 @@ export const dict = {
"command.session.new": "Neue Sitzung", "command.session.new": "Neue Sitzung",
"command.file.open": "Datei öffnen", "command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.context.addSelection": "Auswahl zum Kontext hinzufügen", "command.context.addSelection": "Auswahl zum Kontext hinzufügen",
"command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen", "command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen",
"command.input.focus": "Eingabefeld fokussieren",
"command.terminal.toggle": "Terminal umschalten", "command.terminal.toggle": "Terminal umschalten",
"command.fileTree.toggle": "Dateibaum umschalten", "command.fileTree.toggle": "Dateibaum umschalten",
"command.review.toggle": "Überprüfung umschalten", "command.review.toggle": "Überprüfung umschalten",
@ -253,6 +255,7 @@ export const dict = {
"prompt.popover.emptyResults": "Keine passenden Ergebnisse", "prompt.popover.emptyResults": "Keine passenden Ergebnisse",
"prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.popover.emptyCommands": "Keine passenden Befehle",
"prompt.dropzone.label": "Bilder oder PDFs hier ablegen", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
"prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei",
"prompt.slash.badge.custom": "benutzerdefiniert", "prompt.slash.badge.custom": "benutzerdefiniert",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -275,6 +278,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Shell-Befehl konnte nicht gesendet werden", "prompt.toast.shellSendFailed.title": "Shell-Befehl konnte nicht gesendet werden",
"prompt.toast.commandSendFailed.title": "Befehl konnte nicht gesendet werden", "prompt.toast.commandSendFailed.title": "Befehl konnte nicht gesendet werden",
"prompt.toast.promptSendFailed.title": "Eingabe konnte nicht gesendet werden", "prompt.toast.promptSendFailed.title": "Eingabe konnte nicht gesendet werden",
"prompt.toast.promptSendFailed.description": "Sitzung konnte nicht abgerufen werden",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} von {{total}} aktiviert", "dialog.mcp.description": "{{enabled}} von {{total}} aktiviert",

View file

@ -47,6 +47,7 @@ export const dict = {
"command.tab.close": "Close tab", "command.tab.close": "Close tab",
"command.context.addSelection": "Add selection to context", "command.context.addSelection": "Add selection to context",
"command.context.addSelection.description": "Add selected lines from the current file", "command.context.addSelection.description": "Add selected lines from the current file",
"command.input.focus": "Focus input",
"command.terminal.toggle": "Toggle terminal", "command.terminal.toggle": "Toggle terminal",
"command.fileTree.toggle": "Toggle file tree", "command.fileTree.toggle": "Toggle file tree",
"command.review.toggle": "Toggle review", "command.review.toggle": "Toggle review",
@ -207,8 +208,8 @@ export const dict = {
"model.tooltip.context": "Context limit {{limit}}", "model.tooltip.context": "Context limit {{limit}}",
"common.search.placeholder": "Search", "common.search.placeholder": "Search",
"common.goBack": "Back", "common.goBack": "Navigate back",
"common.goForward": "Forward", "common.goForward": "Navigate forward",
"common.loading": "Loading", "common.loading": "Loading",
"common.loading.ellipsis": "...", "common.loading.ellipsis": "...",
"common.cancel": "Cancel", "common.cancel": "Cancel",
@ -256,6 +257,7 @@ export const dict = {
"prompt.popover.emptyResults": "No matching results", "prompt.popover.emptyResults": "No matching results",
"prompt.popover.emptyCommands": "No matching commands", "prompt.popover.emptyCommands": "No matching commands",
"prompt.dropzone.label": "Drop images or PDFs here", "prompt.dropzone.label": "Drop images or PDFs here",
"prompt.dropzone.file.label": "Drop to @mention file",
"prompt.slash.badge.custom": "custom", "prompt.slash.badge.custom": "custom",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -277,6 +279,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Failed to send shell command", "prompt.toast.shellSendFailed.title": "Failed to send shell command",
"prompt.toast.commandSendFailed.title": "Failed to send command", "prompt.toast.commandSendFailed.title": "Failed to send command",
"prompt.toast.promptSendFailed.title": "Failed to send prompt", "prompt.toast.promptSendFailed.title": "Failed to send prompt",
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} of {{total}} enabled", "dialog.mcp.description": "{{enabled}} of {{total}} enabled",
@ -585,6 +588,7 @@ export const dict = {
"settings.general.section.notifications": "System notifications", "settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates", "settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects", "settings.general.section.sounds": "Sound effects",
"settings.general.section.display": "Display",
"settings.general.row.language.title": "Language", "settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode", "settings.general.row.language.description": "Change the display language for OpenCode",
@ -595,6 +599,11 @@ export const dict = {
"settings.general.row.font.title": "Font", "settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Customise the mono font used in code blocks", "settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
"settings.general.row.wayland.tooltip":
"On Linux with mixed refresh-rate monitors, native Wayland can be more stable.",
"settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.title": "Release notes",
"settings.general.row.releaseNotes.description": "Show What's New popups after updates", "settings.general.row.releaseNotes.description": "Show What's New popups after updates",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "Nueva sesión", "command.session.new": "Nueva sesión",
"command.file.open": "Abrir archivo", "command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.context.addSelection": "Añadir selección al contexto", "command.context.addSelection": "Añadir selección al contexto",
"command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual", "command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual",
"command.input.focus": "Enfocar entrada",
"command.terminal.toggle": "Alternar terminal", "command.terminal.toggle": "Alternar terminal",
"command.fileTree.toggle": "Alternar árbol de archivos", "command.fileTree.toggle": "Alternar árbol de archivos",
"command.review.toggle": "Alternar revisión", "command.review.toggle": "Alternar revisión",
@ -211,6 +213,7 @@ export const dict = {
"prompt.popover.emptyResults": "Sin resultados coincidentes", "prompt.popover.emptyResults": "Sin resultados coincidentes",
"prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.popover.emptyCommands": "Sin comandos coincidentes",
"prompt.dropzone.label": "Suelta imágenes o PDFs aquí", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
"prompt.dropzone.file.label": "Suelta para @mencionar archivo",
"prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -232,6 +235,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Fallo al enviar comando de shell", "prompt.toast.shellSendFailed.title": "Fallo al enviar comando de shell",
"prompt.toast.commandSendFailed.title": "Fallo al enviar comando", "prompt.toast.commandSendFailed.title": "Fallo al enviar comando",
"prompt.toast.promptSendFailed.title": "Fallo al enviar prompt", "prompt.toast.promptSendFailed.title": "Fallo al enviar prompt",
"prompt.toast.promptSendFailed.description": "No se pudo recuperar la sesión",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados", "dialog.mcp.description": "{{enabled}} de {{total}} habilitados",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "Nouvelle session", "command.session.new": "Nouvelle session",
"command.file.open": "Ouvrir un fichier", "command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.context.addSelection": "Ajouter la sélection au contexte", "command.context.addSelection": "Ajouter la sélection au contexte",
"command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel", "command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel",
"command.input.focus": "Focus input",
"command.terminal.toggle": "Basculer le terminal", "command.terminal.toggle": "Basculer le terminal",
"command.fileTree.toggle": "Basculer l'arborescence des fichiers", "command.fileTree.toggle": "Basculer l'arborescence des fichiers",
"command.review.toggle": "Basculer la revue", "command.review.toggle": "Basculer la revue",
@ -211,6 +213,7 @@ export const dict = {
"prompt.popover.emptyResults": "Aucun résultat correspondant", "prompt.popover.emptyResults": "Aucun résultat correspondant",
"prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.popover.emptyCommands": "Aucune commande correspondante",
"prompt.dropzone.label": "Déposez des images ou des PDF ici", "prompt.dropzone.label": "Déposez des images ou des PDF ici",
"prompt.dropzone.file.label": "Déposez pour @mentionner le fichier",
"prompt.slash.badge.custom": "personnalisé", "prompt.slash.badge.custom": "personnalisé",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -232,6 +235,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Échec de l'envoi de la commande shell", "prompt.toast.shellSendFailed.title": "Échec de l'envoi de la commande shell",
"prompt.toast.commandSendFailed.title": "Échec de l'envoi de la commande", "prompt.toast.commandSendFailed.title": "Échec de l'envoi de la commande",
"prompt.toast.promptSendFailed.title": "Échec de l'envoi du message", "prompt.toast.promptSendFailed.title": "Échec de l'envoi du message",
"prompt.toast.promptSendFailed.description": "Impossible de récupérer la session",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} sur {{total}} activés", "dialog.mcp.description": "{{enabled}} sur {{total}} activés",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "新しいセッション", "command.session.new": "新しいセッション",
"command.file.open": "ファイルを開く", "command.file.open": "ファイルを開く",
"command.tab.close": "タブを閉じる",
"command.context.addSelection": "選択範囲をコンテキストに追加", "command.context.addSelection": "選択範囲をコンテキストに追加",
"command.context.addSelection.description": "現在のファイルから選択した行を追加", "command.context.addSelection.description": "現在のファイルから選択した行を追加",
"command.input.focus": "入力欄にフォーカス",
"command.terminal.toggle": "ターミナルの切り替え", "command.terminal.toggle": "ターミナルの切り替え",
"command.fileTree.toggle": "ファイルツリーを切り替え", "command.fileTree.toggle": "ファイルツリーを切り替え",
"command.review.toggle": "レビューの切り替え", "command.review.toggle": "レビューの切り替え",
@ -210,6 +212,7 @@ export const dict = {
"prompt.popover.emptyResults": "一致する結果がありません", "prompt.popover.emptyResults": "一致する結果がありません",
"prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.popover.emptyCommands": "一致するコマンドがありません",
"prompt.dropzone.label": "画像またはPDFをここにドロップ", "prompt.dropzone.label": "画像またはPDFをここにドロップ",
"prompt.dropzone.file.label": "ドロップして@メンションファイルを追加",
"prompt.slash.badge.custom": "カスタム", "prompt.slash.badge.custom": "カスタム",
"prompt.slash.badge.skill": "スキル", "prompt.slash.badge.skill": "スキル",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -231,6 +234,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "シェルコマンドの送信に失敗しました", "prompt.toast.shellSendFailed.title": "シェルコマンドの送信に失敗しました",
"prompt.toast.commandSendFailed.title": "コマンドの送信に失敗しました", "prompt.toast.commandSendFailed.title": "コマンドの送信に失敗しました",
"prompt.toast.promptSendFailed.title": "プロンプトの送信に失敗しました", "prompt.toast.promptSendFailed.title": "プロンプトの送信に失敗しました",
"prompt.toast.promptSendFailed.description": "セッションを取得できませんでした",
"dialog.mcp.title": "MCP", "dialog.mcp.title": "MCP",
"dialog.mcp.description": "{{total}}個中{{enabled}}個が有効", "dialog.mcp.description": "{{total}}個中{{enabled}}個が有効",

View file

@ -48,8 +48,10 @@ export const dict = {
"command.session.new": "새 세션", "command.session.new": "새 세션",
"command.file.open": "파일 열기", "command.file.open": "파일 열기",
"command.tab.close": "탭 닫기",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가", "command.context.addSelection": "선택 영역을 컨텍스트에 추가",
"command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가", "command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가",
"command.input.focus": "입력창 포커스",
"command.terminal.toggle": "터미널 토글", "command.terminal.toggle": "터미널 토글",
"command.fileTree.toggle": "파일 트리 토글", "command.fileTree.toggle": "파일 트리 토글",
"command.review.toggle": "검토 토글", "command.review.toggle": "검토 토글",
@ -214,6 +216,7 @@ export const dict = {
"prompt.popover.emptyResults": "일치하는 결과 없음", "prompt.popover.emptyResults": "일치하는 결과 없음",
"prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.popover.emptyCommands": "일치하는 명령어 없음",
"prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요",
"prompt.dropzone.file.label": "드롭하여 파일 @멘션 추가",
"prompt.slash.badge.custom": "사용자 지정", "prompt.slash.badge.custom": "사용자 지정",
"prompt.slash.badge.skill": "스킬", "prompt.slash.badge.skill": "스킬",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -235,6 +238,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "셸 명령 전송 실패", "prompt.toast.shellSendFailed.title": "셸 명령 전송 실패",
"prompt.toast.commandSendFailed.title": "명령 전송 실패", "prompt.toast.commandSendFailed.title": "명령 전송 실패",
"prompt.toast.promptSendFailed.title": "프롬프트 전송 실패", "prompt.toast.promptSendFailed.title": "프롬프트 전송 실패",
"prompt.toast.promptSendFailed.description": "세션을 가져올 수 없습니다",
"dialog.mcp.title": "MCP", "dialog.mcp.title": "MCP",
"dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨", "dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨",

View file

@ -47,8 +47,10 @@ export const dict = {
"command.session.new": "Ny sesjon", "command.session.new": "Ny sesjon",
"command.file.open": "Åpne fil", "command.file.open": "Åpne fil",
"command.tab.close": "Lukk fane",
"command.context.addSelection": "Legg til markering i kontekst", "command.context.addSelection": "Legg til markering i kontekst",
"command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil", "command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil",
"command.input.focus": "Fokuser inndata",
"command.terminal.toggle": "Veksle terminal", "command.terminal.toggle": "Veksle terminal",
"command.fileTree.toggle": "Veksle filtre", "command.fileTree.toggle": "Veksle filtre",
"command.review.toggle": "Veksle gjennomgang", "command.review.toggle": "Veksle gjennomgang",
@ -214,6 +216,7 @@ export const dict = {
"prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyResults": "Ingen matchende resultater",
"prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slipp bilder eller PDF-er her", "prompt.dropzone.label": "Slipp bilder eller PDF-er her",
"prompt.dropzone.file.label": "Slipp for å @nevne fil",
"prompt.slash.badge.custom": "egendefinert", "prompt.slash.badge.custom": "egendefinert",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -235,6 +238,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando", "prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando",
"prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando", "prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando",
"prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørsel", "prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørsel",
"prompt.toast.promptSendFailed.description": "Kunne ikke hente økt",
"dialog.mcp.title": "MCP-er", "dialog.mcp.title": "MCP-er",
"dialog.mcp.description": "{{enabled}} av {{total}} aktivert", "dialog.mcp.description": "{{enabled}} av {{total}} aktivert",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "Nowa sesja", "command.session.new": "Nowa sesja",
"command.file.open": "Otwórz plik", "command.file.open": "Otwórz plik",
"command.tab.close": "Zamknij kartę",
"command.context.addSelection": "Dodaj zaznaczenie do kontekstu", "command.context.addSelection": "Dodaj zaznaczenie do kontekstu",
"command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku", "command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku",
"command.input.focus": "Fokus na pole wejściowe",
"command.terminal.toggle": "Przełącz terminal", "command.terminal.toggle": "Przełącz terminal",
"command.fileTree.toggle": "Przełącz drzewo plików", "command.fileTree.toggle": "Przełącz drzewo plików",
"command.review.toggle": "Przełącz przegląd", "command.review.toggle": "Przełącz przegląd",
@ -211,6 +213,7 @@ export const dict = {
"prompt.popover.emptyResults": "Brak pasujących wyników", "prompt.popover.emptyResults": "Brak pasujących wyników",
"prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.popover.emptyCommands": "Brak pasujących poleceń",
"prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
"prompt.dropzone.file.label": "Upuść, aby @wspomnieć plik",
"prompt.slash.badge.custom": "własne", "prompt.slash.badge.custom": "własne",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -232,6 +235,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Nie udało się wysłać polecenia powłoki", "prompt.toast.shellSendFailed.title": "Nie udało się wysłać polecenia powłoki",
"prompt.toast.commandSendFailed.title": "Nie udało się wysłać polecenia", "prompt.toast.commandSendFailed.title": "Nie udało się wysłać polecenia",
"prompt.toast.promptSendFailed.title": "Nie udało się wysłać zapytania", "prompt.toast.promptSendFailed.title": "Nie udało się wysłać zapytania",
"prompt.toast.promptSendFailed.description": "Nie udało się pobrać sesji",
"dialog.mcp.title": "MCP", "dialog.mcp.title": "MCP",
"dialog.mcp.description": "{{enabled}} z {{total}} włączone", "dialog.mcp.description": "{{enabled}} z {{total}} włączone",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "Новая сессия", "command.session.new": "Новая сессия",
"command.file.open": "Открыть файл", "command.file.open": "Открыть файл",
"command.tab.close": "Закрыть вкладку",
"command.context.addSelection": "Добавить выделение в контекст", "command.context.addSelection": "Добавить выделение в контекст",
"command.context.addSelection.description": "Добавить выбранные строки из текущего файла", "command.context.addSelection.description": "Добавить выбранные строки из текущего файла",
"command.input.focus": "Фокус на поле ввода",
"command.terminal.toggle": "Переключить терминал", "command.terminal.toggle": "Переключить терминал",
"command.fileTree.toggle": "Переключить дерево файлов", "command.fileTree.toggle": "Переключить дерево файлов",
"command.review.toggle": "Переключить обзор", "command.review.toggle": "Переключить обзор",
@ -211,6 +213,7 @@ export const dict = {
"prompt.popover.emptyResults": "Нет совпадений", "prompt.popover.emptyResults": "Нет совпадений",
"prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.popover.emptyCommands": "Нет совпадающих команд",
"prompt.dropzone.label": "Перетащите изображения или PDF сюда", "prompt.dropzone.label": "Перетащите изображения или PDF сюда",
"prompt.dropzone.file.label": "Отпустите для @упоминания файла",
"prompt.slash.badge.custom": "своё", "prompt.slash.badge.custom": "своё",
"prompt.slash.badge.skill": "навык", "prompt.slash.badge.skill": "навык",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -232,6 +235,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "Не удалось отправить команду оболочки", "prompt.toast.shellSendFailed.title": "Не удалось отправить команду оболочки",
"prompt.toast.commandSendFailed.title": "Не удалось отправить команду", "prompt.toast.commandSendFailed.title": "Не удалось отправить команду",
"prompt.toast.promptSendFailed.title": "Не удалось отправить запрос", "prompt.toast.promptSendFailed.title": "Не удалось отправить запрос",
"prompt.toast.promptSendFailed.description": "Не удалось получить сессию",
"dialog.mcp.title": "MCP", "dialog.mcp.title": "MCP",
"dialog.mcp.description": "{{enabled}} из {{total}} включено", "dialog.mcp.description": "{{enabled}} из {{total}} включено",

View file

@ -44,8 +44,10 @@ export const dict = {
"command.session.new": "เซสชันใหม่", "command.session.new": "เซสชันใหม่",
"command.file.open": "เปิดไฟล์", "command.file.open": "เปิดไฟล์",
"command.tab.close": "ปิดแท็บ",
"command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท", "command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
"command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน", "command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน",
"command.input.focus": "โฟกัสช่องป้อนข้อมูล",
"command.terminal.toggle": "สลับเทอร์มินัล", "command.terminal.toggle": "สลับเทอร์มินัล",
"command.fileTree.toggle": "สลับต้นไม้ไฟล์", "command.fileTree.toggle": "สลับต้นไม้ไฟล์",
"command.review.toggle": "สลับการตรวจสอบ", "command.review.toggle": "สลับการตรวจสอบ",
@ -216,6 +218,7 @@ export const dict = {
"prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน", "prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน",
"prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
"prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
"prompt.dropzone.file.label": "วางเพื่อ @กล่าวถึงไฟล์",
"prompt.slash.badge.custom": "กำหนดเอง", "prompt.slash.badge.custom": "กำหนดเอง",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -237,6 +240,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "ไม่สามารถส่งคำสั่งเชลล์", "prompt.toast.shellSendFailed.title": "ไม่สามารถส่งคำสั่งเชลล์",
"prompt.toast.commandSendFailed.title": "ไม่สามารถส่งคำสั่ง", "prompt.toast.commandSendFailed.title": "ไม่สามารถส่งคำสั่ง",
"prompt.toast.promptSendFailed.title": "ไม่สามารถส่งพร้อมท์", "prompt.toast.promptSendFailed.title": "ไม่สามารถส่งพร้อมท์",
"prompt.toast.promptSendFailed.description": "ไม่สามารถดึงเซสชันได้",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} จาก {{total}} ที่เปิดใช้งาน", "dialog.mcp.description": "{{enabled}} จาก {{total}} ที่เปิดใช้งาน",

View file

@ -48,8 +48,10 @@ export const dict = {
"command.session.new": "新建会话", "command.session.new": "新建会话",
"command.file.open": "打开文件", "command.file.open": "打开文件",
"command.tab.close": "关闭标签页",
"command.context.addSelection": "将所选内容添加到上下文", "command.context.addSelection": "将所选内容添加到上下文",
"command.context.addSelection.description": "添加当前文件中选中的行", "command.context.addSelection.description": "添加当前文件中选中的行",
"command.input.focus": "聚焦输入框",
"command.terminal.toggle": "切换终端", "command.terminal.toggle": "切换终端",
"command.fileTree.toggle": "切换文件树", "command.fileTree.toggle": "切换文件树",
"command.review.toggle": "切换审查", "command.review.toggle": "切换审查",
@ -252,6 +254,7 @@ export const dict = {
"prompt.popover.emptyResults": "没有匹配的结果", "prompt.popover.emptyResults": "没有匹配的结果",
"prompt.popover.emptyCommands": "没有匹配的命令", "prompt.popover.emptyCommands": "没有匹配的命令",
"prompt.dropzone.label": "将图片或 PDF 拖到这里", "prompt.dropzone.label": "将图片或 PDF 拖到这里",
"prompt.dropzone.file.label": "拖放以 @提及文件",
"prompt.slash.badge.custom": "自定义", "prompt.slash.badge.custom": "自定义",
"prompt.slash.badge.skill": "技能", "prompt.slash.badge.skill": "技能",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -273,6 +276,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "发送 shell 命令失败", "prompt.toast.shellSendFailed.title": "发送 shell 命令失败",
"prompt.toast.commandSendFailed.title": "发送命令失败", "prompt.toast.commandSendFailed.title": "发送命令失败",
"prompt.toast.promptSendFailed.title": "发送提示失败", "prompt.toast.promptSendFailed.title": "发送提示失败",
"prompt.toast.promptSendFailed.description": "无法获取会话",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "已启用 {{enabled}} / {{total}}", "dialog.mcp.description": "已启用 {{enabled}} / {{total}}",

View file

@ -48,8 +48,10 @@ export const dict = {
"command.session.new": "新增工作階段", "command.session.new": "新增工作階段",
"command.file.open": "開啟檔案", "command.file.open": "開啟檔案",
"command.tab.close": "關閉分頁",
"command.context.addSelection": "將選取內容加入上下文", "command.context.addSelection": "將選取內容加入上下文",
"command.context.addSelection.description": "加入目前檔案中選取的行", "command.context.addSelection.description": "加入目前檔案中選取的行",
"command.input.focus": "聚焦輸入框",
"command.terminal.toggle": "切換終端機", "command.terminal.toggle": "切換終端機",
"command.fileTree.toggle": "切換檔案樹", "command.fileTree.toggle": "切換檔案樹",
"command.review.toggle": "切換審查", "command.review.toggle": "切換審查",
@ -249,6 +251,7 @@ export const dict = {
"prompt.popover.emptyResults": "沒有符合的結果", "prompt.popover.emptyResults": "沒有符合的結果",
"prompt.popover.emptyCommands": "沒有符合的命令", "prompt.popover.emptyCommands": "沒有符合的命令",
"prompt.dropzone.label": "將圖片或 PDF 拖到這裡", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡",
"prompt.dropzone.file.label": "拖放以 @提及檔案",
"prompt.slash.badge.custom": "自訂", "prompt.slash.badge.custom": "自訂",
"prompt.slash.badge.skill": "技能", "prompt.slash.badge.skill": "技能",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@ -270,6 +273,7 @@ export const dict = {
"prompt.toast.shellSendFailed.title": "傳送 shell 命令失敗", "prompt.toast.shellSendFailed.title": "傳送 shell 命令失敗",
"prompt.toast.commandSendFailed.title": "傳送命令失敗", "prompt.toast.commandSendFailed.title": "傳送命令失敗",
"prompt.toast.promptSendFailed.title": "傳送提示失敗", "prompt.toast.promptSendFailed.title": "傳送提示失敗",
"prompt.toast.promptSendFailed.description": "無法取得工作階段",
"dialog.mcp.title": "MCP", "dialog.mcp.title": "MCP",
"dialog.mcp.description": "已啟用 {{enabled}} / {{total}}", "dialog.mcp.description": "已啟用 {{enabled}} / {{total}}",

View file

@ -1,3 +1,3 @@
export { PlatformProvider, type Platform } from "./context/platform" export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"

View file

@ -15,6 +15,7 @@ export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const language = useLanguage() const language = useLanguage()
let invalid = ""
const directory = createMemo(() => { const directory = createMemo(() => {
return decode64(params.dir) ?? "" return decode64(params.dir) ?? ""
}) })
@ -22,12 +23,14 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
if (!params.dir) return if (!params.dir) return
if (directory()) return if (directory()) return
if (invalid === params.dir) return
invalid = params.dir
showToast({ showToast({
variant: "error", variant: "error",
title: language.t("common.requestFailed"), title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"), description: language.t("directory.error.invalidUrl"),
}) })
navigate("/") navigate("/", { replace: true })
}) })
return ( return (
<Show when={directory()}> <Show when={directory()}>

View file

@ -25,7 +25,8 @@ export default function Home() {
const homedir = createMemo(() => sync.data.path.home) const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => { const recent = createMemo(() => {
return sync.data.project return sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) .slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5) .slice(0, 5)
}) })

View file

@ -1272,8 +1272,6 @@ export default function Layout(props: ParentProps) {
), ),
) )
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
setBusy(directory, false) setBusy(directory, false)
dismiss() dismiss()
@ -1938,7 +1936,7 @@ export default function Layout(props: ParentProps) {
direction="horizontal" direction="horizontal"
size={layout.sidebar.width()} size={layout.sidebar.width()}
min={244} min={244}
max={window.innerWidth * 0.3 + 64} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244} collapseThreshold={244}
onResize={layout.sidebar.resize} onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close} onCollapse={layout.sidebar.close}

View file

@ -2,7 +2,15 @@ export const deepLinkEvent = "opencode:deep-link"
export const parseDeepLink = (input: string) => { export const parseDeepLink = (input: string) => {
if (!input.startsWith("opencode://")) return if (!input.startsWith("opencode://")) return
const url = new URL(input) if (typeof URL.canParse === "function" && !URL.canParse(input)) return
const url = (() => {
try {
return new URL(input)
} catch {
return undefined
}
})()
if (!url) return
if (url.hostname !== "open-project") return if (url.hostname !== "open-project") return
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (!directory) return if (!directory) return

View file

@ -12,6 +12,27 @@ describe("layout deep links", () => {
expect(parseDeepLink("https://example.com")).toBeUndefined() expect(parseDeepLink("https://example.com")).toBeUndefined()
}) })
test("ignores malformed deep links safely", () => {
expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow()
expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined()
})
test("parses links when URL.canParse is unavailable", () => {
const original = Object.getOwnPropertyDescriptor(URL, "canParse")
Object.defineProperty(URL, "canParse", { configurable: true, value: undefined })
try {
expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
} finally {
if (original) Object.defineProperty(URL, "canParse", original)
if (!original) Reflect.deleteProperty(URL, "canParse")
}
})
test("ignores open-project deep links without directory", () => {
expect(parseDeepLink("opencode://open-project")).toBeUndefined()
expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined()
})
test("collects only valid open-project directories", () => { test("collects only valid open-project directories", () => {
const result = collectOpenProjectDeepLinks([ const result = collectOpenProjectDeepLinks([
"opencode://open-project?directory=/a", "opencode://open-project?directory=/a",
@ -39,6 +60,14 @@ describe("layout workspace helpers", () => {
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
}) })
test("preserves posix and drive roots in workspace key", () => {
expect(workspaceKey("/")).toBe("/")
expect(workspaceKey("///")).toBe("/")
expect(workspaceKey("C:\\")).toBe("C:\\")
expect(workspaceKey("C:\\\\\\")).toBe("C:\\")
expect(workspaceKey("C:///")).toBe("C:/")
})
test("keeps local first while preserving known order", () => { test("keeps local first while preserving known order", () => {
const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
expect(result).toEqual(["/root", "/c", "/b"]) expect(result).toEqual(["/root", "/c", "/b"])

View file

@ -1,7 +1,12 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") export const workspaceKey = (directory: string) => {
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/)
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}`
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/"
return directory.replace(/[\\/]+$/, "")
}
export function sortSessions(now: number) { export function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000 const oneMinuteAgo = now - 60 * 1000
@ -21,7 +26,7 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
export const childMapByParent = (sessions: Session[]) => { export const childMapByParent = (sessions: Session[]) => {
const map = new Map<string, string[]>() const map = new Map<string, string[]>()

View file

@ -21,8 +21,11 @@ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const notification = useNotification() const notification = useNotification()
const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) const unseenCount = createMemo(() =>
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return ( return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
@ -141,7 +144,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const item = ( const item = (
<A <A
href={`${props.slug}/session/${props.session.id}`} href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerEnter={scheduleHoverPrefetch} onPointerEnter={scheduleHoverPrefetch}
onPointerLeave={cancelHoverPrefetch} onPointerLeave={cancelHoverPrefetch}
@ -282,7 +285,7 @@ export const NewSessionItem = (props: {
const tooltip = () => props.mobile || !props.sidebarExpanded() const tooltip = () => props.mobile || !props.sidebarExpanded()
const item = ( const item = (
<A <A
href={`${props.slug}/session`} href={`/${props.slug}/session`}
end end
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => { onClick={() => {

View file

@ -1,6 +1,8 @@
import { useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd" import { createSortable } from "@thisbeyond/solid-dnd"
import { createMediaQuery } from "@solid-primitives/media"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@ -86,6 +88,8 @@ export const SortableWorkspace = (props: {
project: LocalProject project: LocalProject
mobile?: boolean mobile?: boolean
}): JSX.Element => { }): JSX.Element => {
const navigate = useNavigate()
const params = useParams()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
const sortable = createSortable(props.directory) const sortable = createSortable(props.directory)
@ -111,8 +115,10 @@ export const SortableWorkspace = (props: {
const busy = createMemo(() => props.ctx.isBusy(props.directory)) const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false) const wasBusy = createMemo((prev) => prev || busy(), false)
const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
const loadMore = async () => { const loadMore = async () => {
setWorkspaceStore("limit", (limit) => limit + 5) setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.directory) await globalSync.project.loadSessions(props.directory)
} }
@ -163,11 +169,9 @@ export const SortableWorkspace = (props: {
openOnDblClick={false} openOnDblClick={false}
/> />
</Show> </Show>
<Icon <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
name={open() ? "chevron-down" : "chevron-right"} <Icon name={open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
size="small" </div>
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
/>
</div> </div>
) )
@ -192,7 +196,9 @@ export const SortableWorkspace = (props: {
when={workspaceEditActive()} when={workspaceEditActive()}
fallback={ fallback={
<Collapsible.Trigger <Collapsible.Trigger
class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover" class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
menu.open ? "pr-16" : "pr-2"
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
data-action="workspace-toggle" data-action="workspace-toggle"
data-workspace={base64Encode(props.directory)} data-workspace={base64Encode(props.directory)}
> >
@ -200,7 +206,13 @@ export const SortableWorkspace = (props: {
</Collapsible.Trigger> </Collapsible.Trigger>
} }
> >
<div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div> <div
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
menu.open ? "pr-16" : "pr-2"
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
>
{header()}
</div>
</Show> </Show>
<div <div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity" class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
@ -260,6 +272,25 @@ export const SortableWorkspace = (props: {
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
</DropdownMenu> </DropdownMenu>
<Show when={!touch()}>
<Tooltip value={language.t("command.session.new")} placement="top">
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
data-action="workspace-new-session"
data-workspace={base64Encode(props.directory)}
aria-label={language.t("command.session.new")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.ctx.setHoverSession(undefined)
props.ctx.clearHoverProjectSoon()
navigate(`/${slug()}/session`)
}}
/>
</Tooltip>
</Show>
</div> </div>
</div> </div>
</div> </div>
@ -267,13 +298,15 @@ export const SortableWorkspace = (props: {
<Collapsible.Content> <Collapsible.Content>
<nav class="flex flex-col gap-1 px-2"> <nav class="flex flex-col gap-1 px-2">
<NewSessionItem <Show when={showNew()}>
slug={slug()} <NewSessionItem
mobile={props.mobile} slug={slug()}
sidebarExpanded={props.ctx.sidebarExpanded} mobile={props.mobile}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} sidebarExpanded={props.ctx.sidebarExpanded}
setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
/> setHoverSession={props.ctx.setHoverSession}
/>
</Show>
<Show when={loading()}> <Show when={loading()}>
<SessionSkeleton /> <SessionSkeleton />
</Show> </Show>
@ -335,7 +368,7 @@ export const LocalWorkspace = (props: {
const loading = createMemo(() => !booted() && sessions().length === 0) const loading = createMemo(() => !booted() && sessions().length === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
const loadMore = async () => { const loadMore = async () => {
workspace().setStore("limit", (limit) => limit + 5) workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree) await globalSync.project.loadSessions(props.project.worktree)
} }

View file

@ -40,7 +40,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf" import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers" import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy" import { createScrollSpy } from "@/pages/session/scroll-spy"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
@ -591,7 +591,7 @@ export default function Page() {
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create" if (store.newSessionWorktree === "create") return "create"
const project = sync.project const project = sync.project
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory if (project && sdk.directory !== project.worktree) return sdk.directory
return "main" return "main"
}) })
@ -844,11 +844,9 @@ export default function Page() {
const { draggable, droppable } = event const { draggable, droppable } = event
if (draggable && droppable) { if (draggable && droppable) {
const currentTabs = tabs().all() const currentTabs = tabs().all()
const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (toIndex === undefined) return
if (fromIndex !== toIndex && toIndex !== undefined) { tabs().move(draggable.id.toString(), toIndex)
tabs().move(draggable.id.toString(), toIndex)
}
} }
} }
@ -917,6 +915,8 @@ export default function Page() {
setFileTreeTab("all") setFileTreeTab("all")
} }
const focusInput = () => inputRef?.focus()
useSessionCommands({ useSessionCommands({
command, command,
dialog, dialog,
@ -943,6 +943,7 @@ export default function Page() {
setExpanded: (id, fn) => setStore("expanded", id, fn), setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage, setActiveMessage,
addSelectionToContext, addSelectionToContext,
focusInput,
}) })
const openReviewFile = createOpenReviewFile({ const openReviewFile = createOpenReviewFile({
@ -1025,10 +1026,31 @@ export default function Page() {
</Show> </Show>
</Match> </Match>
<Match when={true}> <Match when={true}>
<div class={input.emptyClass}> <SessionReviewTab
<Mark class="w-14 opacity-10" /> title={changesTitle()}
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div> empty={
</div> store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match> </Match>
</Switch> </Switch>
) )
@ -1040,7 +1062,7 @@ export default function Page() {
diffStyle: layout.review.diffStyle(), diffStyle: layout.review.diffStyle(),
onDiffStyleChange: layout.review.setDiffStyle, onDiffStyleChange: layout.review.setDiffStyle,
loadingClass: "px-6 py-4 text-text-weak", loadingClass: "px-6 py-4 text-text-weak",
emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6", emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})} })}
</div> </div>
</div> </div>
@ -1568,7 +1590,7 @@ export default function Page() {
container: "px-4", container: "px-4",
}, },
loadingClass: "px-4 py-4 text-text-weak", loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6", emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})} })}
scroll={ui.scroll} scroll={ui.scroll}
onResumeScroll={resumeScroll} onResumeScroll={resumeScroll}
@ -1646,7 +1668,7 @@ export default function Page() {
const target = value === "main" ? sync.project?.worktree : value const target = value === "main" ? sync.project?.worktree : value
if (!target) return if (!target) return
if (target === sync.data.path.directory) return if (target === sdk.directory) return
layout.projects.open(target) layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`) navigate(`/${base64Encode(target)}/session`)
}} }}
@ -1682,7 +1704,7 @@ export default function Page() {
direction="horizontal" direction="horizontal"
size={layout.session.width()} size={layout.session.width()}
min={450} min={450}
max={window.innerWidth * 0.45} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
onResize={layout.session.resize} onResize={layout.session.resize}
/> />
</Show> </Show>

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers" import { combineCommandSections, createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
describe("createOpenReviewFile", () => { describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => { test("opens and loads selected review file", () => {
@ -59,3 +59,13 @@ describe("combineCommandSections", () => {
expect(result.map((item) => item.id)).toEqual(["a", "b", "c"]) expect(result.map((item) => item.id)).toEqual(["a", "b", "c"])
}) })
}) })
describe("getTabReorderIndex", () => {
test("returns target index for valid drag reorder", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
})
test("returns undefined for unknown droppable id", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined()
})
})

View file

@ -36,3 +36,10 @@ export const createOpenReviewFile = (input: {
export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => { export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
return sections.flatMap((section) => section) return sections.flatMap((section) => section)
} }
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
const fromIndex = tabs.indexOf(from)
const toIndex = tabs.indexOf(to)
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
return toIndex
}

View file

@ -179,7 +179,7 @@ export function MessageTimeline(props: {
"sticky top-0 z-30 bg-background-stronger": true, "sticky top-0 z-30 bg-background-stronger": true,
"w-full": true, "w-full": true,
"px-4 md:px-6": true, "px-4 md:px-6": true,
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<div class="h-10 w-full flex items-center justify-between gap-2"> <div class="h-10 w-full flex items-center justify-between gap-2">
@ -278,7 +278,7 @@ export function MessageTimeline(props: {
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{ classList={{
"w-full": true, "w-full": true,
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"mt-0.5": props.centered, "mt-0.5": props.centered,
"mt-0": !props.centered, "mt-0": !props.centered,
}} }}
@ -321,7 +321,7 @@ export function MessageTimeline(props: {
}} }}
classList={{ classList={{
"min-w-0 w-full max-w-full": true, "min-w-0 w-full max-w-full": true,
"md:max-w-200 3xl:max-w-[1200px]": props.centered, "md:max-w-200 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<SessionTurn <SessionTurn

View file

@ -228,6 +228,7 @@ export const createScrollSpy = (input: Input) => {
node.delete(key) node.delete(key)
visible.delete(key) visible.delete(key)
dirty = true dirty = true
schedule()
} }
const markDirty = () => { const markDirty = () => {

View file

@ -31,7 +31,7 @@ export function SessionPromptDock(props: {
<div <div
classList={{ classList={{
"w-full px-4 pointer-events-auto": true, "w-full px-4 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<Show when={props.questionRequest()} keyed> <Show when={props.questionRequest()} keyed>

View file

@ -41,7 +41,7 @@ export function TerminalPanel(props: {
direction="vertical" direction="vertical"
size={props.height} size={props.height}
min={100} min={100}
max={window.innerHeight * 0.6} max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50} collapseThreshold={50}
onResize={props.resize} onResize={props.resize}
onCollapse={props.close} onCollapse={props.close}

View file

@ -48,6 +48,7 @@ export const useSessionCommands = (input: {
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
}) => { }) => {
const sessionCommands = createMemo(() => [ const sessionCommands = createMemo(() => [
{ {
@ -142,6 +143,13 @@ export const useSessionCommands = (input: {
keybind: "mod+\\", keybind: "mod+\\",
onSelect: () => input.layout.fileTree.toggle(), onSelect: () => input.layout.fileTree.toggle(),
}, },
{
id: "input.focus",
title: input.language.t("command.input.focus"),
category: input.language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: () => input.focusInput(),
},
{ {
id: "terminal.new", id: "terminal.new",
title: input.language.t("command.terminal.new"), title: input.language.t("command.terminal.new"),
@ -357,37 +365,81 @@ export const useSessionCommands = (input: {
return [ return [
{ {
id: "session.share", id: "session.share",
title: input.language.t("command.session.share"), title: input.info()?.share?.url
description: input.language.t("command.session.share.description"), ? 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"),
category: input.language.t("command.category.session"), category: input.language.t("command.category.session"),
slash: "share", slash: "share",
disabled: !input.params.id || !!input.info()?.share?.url, disabled: !input.params.id,
onSelect: async () => { onSelect: async () => {
if (!input.params.id) return if (!input.params.id) return
await input.sdk.client.session
.share({ sessionID: input.params.id }) const write = (value: string) => {
.then((res) => { const body = typeof document === "undefined" ? undefined : document.body
navigator.clipboard.writeText(res.data!.share!.url).catch(() => if (body) {
showToast({ const textarea = document.createElement("textarea")
title: input.language.t("toast.session.share.copyFailed.title"), textarea.value = value
variant: "error", textarea.setAttribute("readonly", "")
}), textarea.style.position = "fixed"
) textarea.style.opacity = "0"
}) textarea.style.pointerEvents = "none"
.then(() => body.appendChild(textarea)
showToast({ textarea.select()
title: input.language.t("toast.session.share.success.title"), const copied = document.execCommand("copy")
description: input.language.t("toast.session.share.success.description"), body.removeChild(textarea)
variant: "success", if (copied) return Promise.resolve(true)
}), }
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false)
return clipboard.writeText(value).then(
() => true,
() => false,
) )
.catch(() => }
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({ showToast({
title: input.language.t("toast.session.share.failed.title"), title: input.language.t("toast.session.share.copyFailed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error", variant: "error",
}), })
) return
}
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"),
variant: "success",
})
}
const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const url = await input.sdk.client.session
.share({ sessionID: input.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"),
variant: "error",
})
return
}
await copy(url, false)
}, },
}, },
{ {

View file

@ -0,0 +1,107 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
type PersistTestingType = typeof import("./persist").PersistTesting
class MemoryStorage implements Storage {
private values = new Map<string, string>()
readonly events: string[] = []
readonly calls = { get: 0, set: 0, remove: 0 }
clear() {
this.values.clear()
}
get length() {
return this.values.size
}
key(index: number) {
return Array.from(this.values.keys())[index] ?? null
}
getItem(key: string) {
this.calls.get += 1
this.events.push(`get:${key}`)
if (key.startsWith("opencode.throw")) throw new Error("storage get failed")
return this.values.get(key) ?? null
}
setItem(key: string, value: string) {
this.calls.set += 1
this.events.push(`set:${key}`)
if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError")
if (key.startsWith("opencode.throw")) throw new Error("storage set failed")
this.values.set(key, value)
}
removeItem(key: string) {
this.calls.remove += 1
this.events.push(`remove:${key}`)
if (key.startsWith("opencode.throw")) throw new Error("storage remove failed")
this.values.delete(key)
}
}
const storage = new MemoryStorage()
let persistTesting: PersistTestingType
beforeAll(async () => {
mock.module("@/context/platform", () => ({
usePlatform: () => ({ platform: "web" }),
}))
const mod = await import("./persist")
persistTesting = mod.PersistTesting
})
beforeEach(() => {
storage.clear()
storage.events.length = 0
storage.calls.get = 0
storage.calls.set = 0
storage.calls.remove = 0
Object.defineProperty(globalThis, "localStorage", {
value: storage,
configurable: true,
})
})
describe("persist localStorage resilience", () => {
test("does not cache values as persisted when quota write and eviction fail", () => {
const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope")
storageApi.setItem("value", '{"value":1}')
expect(storage.getItem("opencode.quota.scope:value")).toBeNull()
expect(storageApi.getItem("value")).toBeNull()
})
test("disables only the failing scope when storage throws", () => {
const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope")
bad.setItem("value", '{"value":1}')
const before = storage.calls.set
bad.setItem("value", '{"value":2}')
expect(storage.calls.set).toBe(before)
expect(bad.getItem("value")).toBeNull()
const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope")
healthy.setItem("value", '{"value":3}')
expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}')
})
test("failing fallback scope does not poison direct storage scope", () => {
const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2")
broken.setItem("value", '{"value":1}')
const direct = persistTesting.localStorageDirect()
direct.setItem("direct-value", '{"value":5}')
expect(storage.getItem("direct-value")).toBe('{"value":5}')
})
test("normalizer rejects malformed JSON payloads", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
})

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