Compare commits

..

3 Commits

Author SHA1 Message Date
Brendan Allan
61c4d0a0d0 desktop: align platform typing for ssh helpers 2026-02-10 12:21:54 +08:00
Brendan Allan
7c6d82b79a desktop: support Windows SSH sessions 2026-02-10 12:11:35 +08:00
Brendan Allan
3d63f86d19 desktop: remote ssh connections 2026-02-09 16:40:58 +08:00
721 changed files with 3305 additions and 194600 deletions

View File

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

18
.github/VOUCHED.td vendored
View File

@@ -1,18 +0,0 @@
# 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,86 +0,0 @@
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,12 +48,8 @@ jobs:
TODAY'S DATE: ${TODAY}
STEP 1: Gather today's issues
Search for all OPEN issues created today (${TODAY}) using:
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.
Search for all 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
STEP 2: Analyze and categorize
For each issue created today, categorize it:

View File

@@ -47,18 +47,14 @@ jobs:
TODAY'S DATE: ${TODAY}
STEP 1: Gather PR data
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
Run these commands to gather PR information. ONLY include PRs created or updated TODAY (${TODAY}):
# Open PRs created today
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 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
# Open PRs with activity today (updated today)
# 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
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

View File

@@ -1,82 +0,0 @@
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
run: curl -fsSL https://opencode.ai/install | bash
- name: Check duplicates and compliance
- name: Check for duplicate issues
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -34,84 +34,30 @@ jobs:
"webfetch": "deny"
}
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: ${{ github.event.issue.number }}
Issue number:
${{ github.event.issue.number }}
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.
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.
Consider:
1. Similar titles or descriptions
2. Same error messages or symptoms
3. Related functionality or components
4. Similar feature requests
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
---
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.
If you find any potential duplicates, please comment on the new issue with:
- A brief explanation of why it might be a duplicate
- Links to the potentially duplicate issues
- A suggestion to check those issues first
Use this format for the comment:
[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:
'This issue might be a duplicate of existing issues. Please check:
- #[issue_number]: [brief description of similarity]
[If keybind-related, add:]
For keybind-related issues, please also check our pinned keybinds documentation: #4997
Feel free to ignore if none of these address your specific case.'
[End with if not compliant:]
If you believe this was flagged incorrectly, please let a maintainer know.
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, please add a comment mentioning the pinned keybinds issue #4997:
'For keybind-related issues, please also check our pinned keybinds documentation: #4997'
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
If no clear duplicates are found, do not comment."

View File

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

View File

@@ -1,96 +0,0 @@
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}`);

View File

@@ -1,93 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -258,49 +258,3 @@ These are not strictly enforced, they are just general guidelines:
## 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.
## 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.

122
bun.lock
View File

@@ -470,7 +470,7 @@
"@astrojs/solid-js": "5.1.0",
"@astrojs/starlight": "0.34.3",
"@fontsource/ibm-plex-mono": "5.2.5",
"@shikijs/transformers": "3.20.0",
"@shikijs/transformers": "3.4.2",
"@types/luxon": "catalog:",
"ai": "catalog:",
"astro": "5.7.13",
@@ -485,10 +485,8 @@
"shiki": "catalog:",
"solid-js": "catalog:",
"toolbeam-docs-theme": "0.4.8",
"vscode-languageserver-types": "3.17.5",
},
"devDependencies": {
"@astrojs/check": "0.9.6",
"@types/node": "catalog:",
"opencode": "workspace:*",
"typescript": "catalog:",
@@ -619,16 +617,12 @@
"@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/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
"@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/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=="],
@@ -645,8 +639,6 @@
"@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/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=="],
@@ -857,20 +849,6 @@
"@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/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -1983,22 +1961,6 @@
"@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=="],
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
@@ -2027,8 +1989,6 @@
"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=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -2431,8 +2391,6 @@
"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-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
@@ -3211,8 +3169,6 @@
"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=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
@@ -3365,8 +3321,6 @@
"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-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -3567,10 +3521,6 @@
"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=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
@@ -3913,12 +3863,8 @@
"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-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=="],
"ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="],
@@ -4015,40 +3961,10 @@
"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-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-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-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
@@ -4109,8 +4025,6 @@
"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-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
@@ -4183,8 +4097,6 @@
"@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/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
@@ -4379,7 +4291,7 @@
"@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.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@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=="],
"@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=="],
@@ -4453,8 +4365,6 @@
"@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=="],
"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=="],
@@ -4681,8 +4591,6 @@
"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=="],
"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=="],
@@ -4693,12 +4601,6 @@
"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=="],
"zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -4719,10 +4621,6 @@
"@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/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
@@ -4967,9 +4865,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/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/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/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@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=="],
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -5213,14 +5111,6 @@
"@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/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=="],
@@ -5361,10 +5251,6 @@
"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=="],
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=",
"aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=",
"aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=",
"x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4="
"x86_64-linux": "sha256-1IpZnnN6+acCcV0AgO4OVdvgf4TFBFId5dms5W5ecA0=",
"aarch64-linux": "sha256-TKmPhXokOav46ucP9AFwHGgKmB9CdGCcUtwqUtLlzG4=",
"aarch64-darwin": "sha256-xJQuw3+QHYnlClDrafQKPQyR+aqyAEofvYYjCowHDps=",
"x86_64-darwin": "sha256-ywU3Oka2QNGKu/HI+//3bdYJ9qo1N7K5Wr2vpTgSM/g="
}
}

View File

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

View File

@@ -131,6 +131,13 @@ export function DialogSelectServer() {
busy: false,
status: undefined as boolean | undefined,
},
ssh: {
command: "",
connecting: false,
error: "",
showForm: false,
},
})
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
@@ -150,6 +157,7 @@ export function DialogSelectServer() {
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const canSsh = createMemo(() => !!platform.sshConnect)
const fetcher = platform.fetch ?? globalThis.fetch
const looksComplete = (value: string) => {
@@ -189,6 +197,15 @@ export function DialogSelectServer() {
})
}
const resetSsh = () => {
setStore("ssh", {
command: "",
connecting: false,
error: "",
showForm: false,
})
}
const replaceServer = (original: string, next: string) => {
const active = server.url
const nextActive = active === original ? next : active
@@ -360,6 +377,35 @@ export function DialogSelectServer() {
}
}
async function handleSshConnect() {
if (!platform.sshConnect) return
if (store.ssh.connecting) return
const command = store.ssh.command.trim()
if (!command) {
resetSsh()
return
}
setStore("ssh", { connecting: true, error: "" })
try {
const result = await platform.sshConnect(command)
const url = normalizeServerUrl(result.url)
if (!url) {
setStore("ssh", { error: language.t("dialog.server.add.error") })
return
}
resetSsh()
await select(url, true)
} catch (err) {
setStore("ssh", {
error: err instanceof Error ? err.message : String(err),
})
} finally {
setStore("ssh", { connecting: false })
}
}
return (
<Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2">
@@ -517,18 +563,80 @@ export function DialogSelectServer() {
</List>
<div class="px-5 pb-5">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{store.addServer.adding
? language.t("dialog.server.add.checking")
: language.t("dialog.server.add.button")}
</Button>
<Show when={canSsh()}>
<Button
variant="secondary"
icon="server"
size="large"
onClick={() => {
setStore("ssh", { showForm: !store.ssh.showForm, error: "" })
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
SSH
</Button>
</Show>
</div>
<Show when={store.ssh.showForm && canSsh()}>
<div class="flex flex-col gap-2">
<TextField
type="text"
hideLabel
placeholder={"ssh user@host"}
value={store.ssh.command}
validationState={store.ssh.error ? "invalid" : "valid"}
error={store.ssh.error}
disabled={store.ssh.connecting}
onChange={(value) => {
if (store.ssh.connecting) return
setStore("ssh", { command: value, error: "" })
}}
onKeyDown={(event: KeyboardEvent) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
resetSsh()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
void handleSshConnect()
}}
/>
<div class="flex items-center gap-2">
<Button
size="normal"
variant="primary"
disabled={store.ssh.connecting}
onClick={() => void handleSshConnect()}
>
{store.ssh.connecting ? "Connecting..." : "Connect"}
</Button>
<Button size="normal" variant="ghost" disabled={store.ssh.connecting} onClick={resetSsh}>
Cancel
</Button>
</div>
</div>
</Show>
</div>
</div>
</div>
</Dialog>

View File

@@ -31,7 +31,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
id: crypto.randomUUID(),
filename: file.name,
mime: file.type,
dataUrl,

View File

@@ -136,8 +136,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation()
const projectDirectory = sdk.directory
if (!projectDirectory) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/")
return
}
const isNewSession = !params.id
const worktreeSelection = input.newSessionWorktree || "main"
const worktreeSelection = input.newSessionWorktree ?? "main"
let sessionDirectory = projectDirectory
let client = sdk.client
@@ -194,7 +203,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
description: errorMessage(err),
})
return undefined
})
});
console.log({sessionDirectory})
if (session) {
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)

View File

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

View File

@@ -1,7 +1,6 @@
import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -16,7 +15,6 @@ interface NewSessionViewProps {
export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
@@ -26,11 +24,11 @@ export function NewSessionView(props: NewSessionViewProps) {
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
const isWorktree = createMemo(() => {
const project = sync.project
if (!project) return false
return sdk.directory !== project.worktree
return sync.data.path.directory !== project.worktree
})
const label = (value: string) => {
@@ -47,7 +45,7 @@ export function NewSessionView(props: NewSessionViewProps) {
}
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 2xl:max-w-[1000px] 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 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="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />

View File

@@ -1,10 +1,8 @@
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { Component, createMemo, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
@@ -42,8 +40,6 @@ export const SettingsGeneral: Component = () => {
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
@@ -414,49 +410,13 @@ export const SettingsGeneral: Component = () => {
</SettingsRow>
</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>
)
}
interface SettingsRowProps {
title: string | JSX.Element
title: string
description: string | JSX.Element
children: JSX.Element
}

View File

@@ -141,7 +141,7 @@ export function StatusPopover() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
"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",
style: { scale: 1 },
}}
trigger={

View File

@@ -166,7 +166,11 @@ export const Terminal = (props: TerminalProps) => {
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
const auth = platform.wsAuth?.(sdk.url)
if (auth) {
url.username = auth.username
url.password = auth.password
} else if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}

View File

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

View File

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

View File

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

View File

@@ -27,37 +27,6 @@ describe("file watcher invalidation", () => {
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", () => {
const refresh: string[] = []

View File

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

View File

@@ -57,11 +57,20 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
/** Override how the app groups server state (projects/history) for a URL */
serverKey?(url: string): string
/** Set the preferred display backend (desktop only) */
setDisplayBackend?(backend: DisplayBackend): Promise<void>
/** Override whether a server URL should be treated as local */
isServerLocal?(url: string): boolean
/** Connect to a remote server over SSH (desktop only) */
sshConnect?(command: string): Promise<{ url: string; key: string; password: string | null }>
/** Disconnect an SSH session (desktop only) */
sshDisconnect?(key: string): Promise<void>
/** Credentials to embed in WebSocket URLs (desktop only) */
wsAuth?(url: string): { username: string; password: string } | null
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
@@ -76,8 +85,6 @@ export type Platform = {
readClipboardImage?(): Promise<File | null>
}
export type DisplayBackend = "auto" | "wayland"
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
name: "Platform",
init: (props: { value: Platform }) => {

View File

@@ -148,9 +148,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
})
const origin = createMemo(() => projectsKey(state.active))
const origin = createMemo(() => {
const url = state.active
if (!url) return ""
return platform.serverKey?.(url) ?? projectsKey(url)
})
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local")
const isLocal = createMemo(() => {
const url = state.active
if (!url) return false
return platform.isServerLocal?.(url) ?? origin() === "local"
})
return {
ready: isReady,

View File

@@ -208,8 +208,8 @@ export const dict = {
"model.tooltip.context": "Context limit {{limit}}",
"common.search.placeholder": "Search",
"common.goBack": "Navigate back",
"common.goForward": "Navigate forward",
"common.goBack": "Back",
"common.goForward": "Forward",
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"common.cancel": "Cancel",
@@ -588,7 +588,6 @@ export const dict = {
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
"settings.general.section.display": "Display",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
@@ -599,11 +598,6 @@ export const dict = {
"settings.general.row.font.title": "Font",
"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.description": "Show What's New popups after updates",

View File

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

View File

@@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) {
})
createEffect(() => {
if (!params.dir) return
if (params.dir === undefined) return
if (directory()) return
if (invalid === params.dir) return
invalid = params.dir

View File

@@ -21,11 +21,8 @@ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const notification = useNotification()
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
const unseenCount = createMemo(() =>
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree))
const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>

View File

@@ -2,7 +2,6 @@ import { useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd"
import { createMediaQuery } from "@solid-primitives/media"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { Button } from "@opencode-ai/ui/button"
@@ -115,8 +114,7 @@ export const SortableWorkspace = (props: {
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false)
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 showNew = createMemo(() => !loading() && (sessions().length === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.directory)
@@ -272,25 +270,23 @@ export const SortableWorkspace = (props: {
</DropdownMenu.Content>
</DropdownMenu.Portal>
</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>
<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>
</div>
</div>
</div>

View File

@@ -591,7 +591,8 @@ export default function Page() {
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
if (project && sdk.directory !== project.worktree) return sdk.directory
const directory = sync.data.path.directory
if (project && directory && directory !== project.worktree) return directory
return "main"
})
@@ -1647,7 +1648,7 @@ export default function Page() {
const target = value === "main" ? sync.project?.worktree : value
if (!target) return
if (target === sdk.directory) return
if (target === sync.data.path.directory) return
layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`)
}}

View File

@@ -179,7 +179,7 @@ export function MessageTimeline(props: {
"sticky top-0 z-30 bg-background-stronger": true,
"w-full": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
}}
>
<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]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
@@ -321,7 +321,7 @@ export function MessageTimeline(props: {
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:max-w-200 3xl:max-w-[1200px]": props.centered,
}}
>
<SessionTurn

View File

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

View File

@@ -68,82 +68,6 @@ const TAG = {
tr: "tr",
} satisfies Record<Locale, string>
const DOCS = {
en: "root",
zh: "zh-cn",
zht: "zh-tw",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
it: "it",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
ar: "ar",
no: "nb",
br: "pt-br",
th: "th",
tr: "tr",
} satisfies Record<Locale, string>
const DOCS_SEGMENT = new Set([
"ar",
"bs",
"da",
"de",
"es",
"fr",
"it",
"ja",
"ko",
"nb",
"pl",
"pt-br",
"ru",
"th",
"tr",
"zh-cn",
"zh-tw",
])
function suffix(pathname: string) {
const index = pathname.search(/[?#]/)
if (index === -1) {
return {
path: fix(pathname),
suffix: "",
}
}
return {
path: fix(pathname.slice(0, index)),
suffix: pathname.slice(index),
}
}
export function docs(locale: Locale, pathname: string) {
const value = DOCS[locale]
const next = suffix(pathname)
if (next.path !== "/docs" && next.path !== "/docs/" && !next.path.startsWith("/docs/")) {
return `${next.path}${next.suffix}`
}
if (value === "root") return `${next.path}${next.suffix}`
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
const head = next.path.slice("/docs/".length).split("/")[0] ?? ""
if (!head) return `/docs/${value}/${next.suffix}`
if (DOCS_SEGMENT.has(head)) return `${next.path}${next.suffix}`
if (head.startsWith("_")) return `${next.path}${next.suffix}`
if (head.includes(".")) return `${next.path}${next.suffix}`
return `/docs/${value}${next.path.slice("/docs".length)}${next.suffix}`
}
export function parseLocale(value: unknown): Locale | null {
if (typeof value !== "string") return null
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
@@ -166,7 +90,7 @@ export function strip(pathname: string) {
export function route(locale: Locale, pathname: string) {
const next = strip(pathname)
if (next.startsWith("/docs")) return docs(locale, next)
if (next.startsWith("/docs")) return next
if (next.startsWith("/auth")) return next
if (next.startsWith("/workspace")) return next
if (locale === "en") return next

View File

@@ -1,16 +1,14 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const locale = localeFromRequest(req)
const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai"
const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}`
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
const headers = new Headers(req.headers)
headers.set("accept-language", tag(locale))
const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie"))
if (locale) headers.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,

View File

@@ -1,16 +1,14 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const locale = localeFromRequest(req)
const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai"
const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}`
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
const headers = new Headers(req.headers)
headers.set("accept-language", tag(locale))
const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie"))
if (locale) headers.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,

View File

@@ -294,7 +294,7 @@ export default function Download() {
</span>
<span>VS Code</span>
</div>
<a href={language.route("/docs/ide/")} data-component="action-button">
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
{i18n.t("download.action.install")}
</a>
</div>
@@ -318,7 +318,7 @@ export default function Download() {
</span>
<span>Cursor</span>
</div>
<a href={language.route("/docs/ide/")} data-component="action-button">
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
{i18n.t("download.action.install")}
</a>
</div>
@@ -335,7 +335,7 @@ export default function Download() {
</span>
<span>Zed</span>
</div>
<a href={language.route("/docs/ide/")} data-component="action-button">
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
{i18n.t("download.action.install")}
</a>
</div>
@@ -352,7 +352,7 @@ export default function Download() {
</span>
<span>Windsurf</span>
</div>
<a href={language.route("/docs/ide/")} data-component="action-button">
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
{i18n.t("download.action.install")}
</a>
</div>
@@ -369,7 +369,7 @@ export default function Download() {
</span>
<span>VSCodium</span>
</div>
<a href={language.route("/docs/ide/")} data-component="action-button">
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
{i18n.t("download.action.install")}
</a>
</div>
@@ -393,7 +393,7 @@ export default function Download() {
</span>
<span>GitHub</span>
</div>
<a href={language.route("/docs/github/")} data-component="action-button">
<a href="https://opencode.ai/docs/github/" data-component="action-button">
{i18n.t("download.action.install")}
</a>
</div>
@@ -410,7 +410,7 @@ export default function Download() {
</span>
<span>GitLab</span>
</div>
<a href={language.route("/docs/gitlab/")} data-component="action-button">
<a href="https://opencode.ai/docs/gitlab/" data-component="action-button">
{i18n.t("download.action.install")}
</a>
</div>

View File

@@ -1,16 +1,14 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const locale = localeFromRequest(req)
const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai"
const targetUrl = `https://${host}${docs(locale, `/docs${url.pathname}`)}${url.search}`
const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}`
const headers = new Headers(req.headers)
headers.set("accept-language", tag(locale))
const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie"))
if (locale) headers.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,

View File

@@ -9,12 +9,10 @@ import { GraphSection } from "./graph-section"
import { IconLogo } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
export default function () {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
@@ -40,7 +38,7 @@ export default function () {
<p>
<span>
{i18n.t("workspace.home.banner.beforeLink")}{" "}
<a target="_blank" href={language.route("/docs/zen")}>
<a target="_blank" href="/docs/zen">
{i18n.t("common.learnMore")}
</a>
.

View File

@@ -3076,6 +3076,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"shell-words",
"specta",
"specta-typescript",
"tauri",
@@ -4423,6 +4424,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"

View File

@@ -34,7 +34,7 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = "1.48.0"
tokio = { version = "1.48.0", features = ["process", "net", "io-util", "time", "sync", "rt", "macros"] }
listeners = "0.3"
tauri-plugin-os = "2"
futures = "0.3.31"
@@ -47,6 +47,7 @@ specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
dirs = "6.0.0"
shell-words = "1.1.0"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"

View File

@@ -2,10 +2,9 @@ mod cli;
mod constants;
#[cfg(windows)]
mod job_object;
#[cfg(target_os = "linux")]
pub mod linux_display;
mod markdown;
mod server;
mod ssh;
mod window_customizer;
mod windows;
@@ -196,43 +195,6 @@ fn check_macos_app(app_name: &str) -> bool {
.unwrap_or(false)
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum LinuxDisplayBackend {
Wayland,
Auto,
}
#[tauri::command]
#[specta::specta]
fn get_display_backend() -> Option<LinuxDisplayBackend> {
#[cfg(target_os = "linux")]
{
let prefer = linux_display::read_wayland().unwrap_or(false);
return Some(if prefer {
LinuxDisplayBackend::Wayland
} else {
LinuxDisplayBackend::Auto
});
}
#[cfg(not(target_os = "linux"))]
None
}
#[tauri::command]
#[specta::specta]
fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
let prefer = matches!(_backend, LinuxDisplayBackend::Wayland);
return linux_display::write_wayland(&_app, prefer);
}
#[cfg(not(target_os = "linux"))]
Ok(())
}
#[cfg(target_os = "linux")]
fn check_linux_app(app_name: &str) -> bool {
return true;
@@ -248,10 +210,11 @@ pub fn run() {
await_initialization,
server::get_default_server_url,
server::set_default_server_url,
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists
check_app_exists,
ssh::ssh_connect,
ssh::ssh_disconnect,
ssh::ssh_prompt_reply
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
@@ -317,6 +280,7 @@ pub fn run() {
println!("Received Exit");
kill_sidecar(app.clone());
ssh::shutdown(app.clone());
}
});
}
@@ -467,6 +431,8 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
app.manage(ssh::SshState::default());
#[cfg(windows)]
app.manage(JobObjectState::new());

View File

@@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::PathBuf;
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
use crate::constants::SETTINGS_STORE;
pub const LINUX_DISPLAY_CONFIG_KEY: &str = "linuxDisplayConfig";
#[derive(Default, Serialize, Deserialize)]
struct DisplayConfig {
wayland: Option<bool>,
}
fn dir() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("ai.opencode.desktop"))
}
fn path() -> Option<PathBuf> {
dir().map(|dir| dir.join(SETTINGS_STORE))
}
pub fn read_wayland() -> Option<bool> {
let path = path()?;
let raw = std::fs::read_to_string(path).ok()?;
let config = serde_json::from_str::<DisplayConfig>(&raw).ok()?;
config.wayland
}
pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
store.set(
LINUX_DISPLAY_CONFIG_KEY,
json!(DisplayConfig {
wayland: Some(value),
}),
);
store
.save()
.map_err(|e| format!("Failed to save settings store: {}", e))?;
Ok(())
}

View File

@@ -23,16 +23,12 @@ fn configure_display_backend() -> Option<String> {
return None;
}
let prefer_wayland = opencode_lib::linux_display::read_wayland().unwrap_or(false);
let allow_wayland = prefer_wayland
|| matches!(
env::var("OC_ALLOW_WAYLAND"),
Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
);
// Allow users to explicitly keep Wayland if they know their setup is stable.
let allow_wayland = matches!(
env::var("OC_ALLOW_WAYLAND"),
Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
);
if allow_wayland {
if prefer_wayland {
return Some("Wayland session detected; using native Wayland from settings".into());
}
return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into());
}
@@ -55,7 +51,80 @@ fn configure_display_backend() -> Option<String> {
)
}
#[cfg(unix)]
fn askpass_stream(socket: &str) -> Result<Box<dyn std::io::Read + std::io::Write>, String> {
if let Some(addr) = socket.strip_prefix("tcp:") {
let stream = std::net::TcpStream::connect(addr)
.map_err(|e| format!("askpass connect failed: {e}"))?;
let boxed: Box<dyn std::io::Read + std::io::Write> = Box::new(stream);
return Ok(boxed);
}
use std::os::unix::net::UnixStream;
let stream = UnixStream::connect(socket).map_err(|e| format!("askpass connect failed: {e}"))?;
let boxed: Box<dyn std::io::Read + std::io::Write> = Box::new(stream);
Ok(boxed)
}
#[cfg(not(unix))]
fn askpass_stream(socket: &str) -> Result<Box<dyn std::io::Read + std::io::Write>, String> {
let addr = socket
.strip_prefix("tcp:")
.ok_or_else(|| "askpass socket is not tcp on this platform".to_string())?;
let stream =
std::net::TcpStream::connect(addr).map_err(|e| format!("askpass connect failed: {e}"))?;
let boxed: Box<dyn std::io::Read + std::io::Write> = Box::new(stream);
Ok(boxed)
}
fn main() {
if let Ok(socket) = std::env::var("OPENCODE_SSH_ASKPASS_SOCKET") {
use std::io::{Read as _, Write as _};
use std::process::exit;
let args = std::env::args().collect::<Vec<_>>();
let prompt = if let Some(pos) = args.iter().position(|a| a == "--ssh-askpass") {
args.iter()
.skip(pos + 2)
.cloned()
.collect::<Vec<_>>()
.join(" ")
} else {
args.iter().skip(1).cloned().collect::<Vec<_>>().join(" ")
};
let mut stream = match askpass_stream(&socket) {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
exit(1);
}
};
let bytes = prompt.as_bytes();
let len = u32::try_from(bytes.len()).unwrap_or(0);
if stream.write_all(&len.to_be_bytes()).is_err() || stream.write_all(bytes).is_err() {
eprintln!("askpass write failed");
exit(1);
}
let mut len_buf = [0u8; 4];
if stream.read_exact(&mut len_buf).is_err() {
eprintln!("askpass read failed");
exit(1);
}
let reply_len = u32::from_be_bytes(len_buf) as usize;
let mut reply = vec![0u8; reply_len];
if stream.read_exact(&mut reply).is_err() {
eprintln!("askpass read failed");
exit(1);
}
let _ = std::io::stdout().write_all(&reply);
let _ = std::io::stdout().write_all(b"\n");
return;
}
// Ensure loopback connections are never sent through proxy settings.
// Some VPNs/proxies set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY without excluding localhost.
const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"];

View File

@@ -0,0 +1,863 @@
use std::{
collections::HashMap,
net::TcpListener,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use tauri::{AppHandle, Emitter as _, Manager};
use tokio::{
io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader},
process::{Child, Command},
sync::{Mutex, oneshot},
};
#[cfg(unix)]
use tokio::net::UnixListener;
#[cfg(not(unix))]
use tokio::net::TcpListener;
use crate::server;
fn log(line: impl AsRef<str>) {
eprintln!("[SSH] {}", line.as_ref());
}
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
pub struct SshConnectData {
pub key: String,
pub url: String,
pub password: String,
pub destination: String,
}
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
pub struct SshPrompt {
pub id: String,
pub prompt: String,
}
#[derive(Default)]
pub struct SshState {
session: Mutex<Option<SshSession>>,
prompts: Mutex<HashMap<String, oneshot::Sender<String>>>,
}
struct SshSession {
key: String,
destination: String,
dir: PathBuf,
askpass_task: tokio::task::JoinHandle<()>,
socket_path: Option<PathBuf>,
master: Option<Child>,
forward: Child,
server: Child,
}
#[derive(Debug, Clone)]
struct Spec {
destination: String,
args: Vec<String>,
}
#[derive(Clone, Debug)]
struct Askpass {
socket: String,
exe: PathBuf,
}
#[derive(Clone, Copy, Debug)]
enum ControlMode {
Master,
Client,
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("Failed to bind to find free port")
.local_addr()
.expect("Failed to get local address")
.port()
}
fn parse_ssh_command(input: &str) -> Result<Spec, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("SSH command is empty".to_string());
}
let without_prefix = trimmed.strip_prefix("ssh ").unwrap_or(trimmed);
let tokens =
shell_words::split(without_prefix).map_err(|e| format!("Invalid SSH command: {e}"))?;
if tokens.is_empty() {
return Err("SSH command is empty".to_string());
}
const ALLOWED_OPTS: &[&str] = &[
"-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
];
const ALLOWED_ARGS: &[&str] = &[
"-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-w",
];
// Disallowed: -E, -e, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W, -L, -R
let mut args = Vec::<String>::new();
let mut i = 0;
let mut destination: Option<String> = None;
while i < tokens.len() {
let tok = &tokens[i];
if destination.is_some() {
return Err(
"SSH command cannot include a remote command; only destination + options are supported"
.to_string(),
);
}
if ALLOWED_OPTS.contains(&tok.as_str()) {
args.push(tok.clone());
i += 1;
continue;
}
if tok == "-L" || tok.starts_with("-L") || tok == "-R" || tok.starts_with("-R") {
return Err("SSH port forwarding flags (-L/-R) are not supported yet".to_string());
}
if tok.starts_with('-') {
let mut matched = false;
for opt in ALLOWED_ARGS {
if tok == opt {
matched = true;
args.push(tok.clone());
i += 1;
if i < tokens.len() {
args.push(tokens[i].clone());
i += 1;
}
break;
}
if tok.starts_with(opt) {
matched = true;
args.push(tok.clone());
i += 1;
break;
}
}
if matched {
continue;
}
return Err(format!("Unsupported ssh argument: {tok}"));
}
destination = Some(tok.clone());
i += 1;
}
let Some(destination) = destination else {
return Err("Missing ssh destination (e.g. user@host)".to_string());
};
Ok(Spec { destination, args })
}
fn sh_quote(input: &str) -> String {
let escaped = input.replace('\'', "'\\'''");
format!("'{}'", escaped)
}
fn exe_path(app: &AppHandle) -> Result<PathBuf, String> {
tauri::process::current_binary(&app.env())
.map_err(|e| format!("Failed to locate current binary: {e}"))
}
async fn ensure_ssh_available() -> Result<(), String> {
let res = Command::new("ssh")
.arg("-V")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
if res.is_err() {
if cfg!(windows) {
return Err(
"ssh.exe was not found on PATH. Install Windows OpenSSH or Git for Windows and ensure ssh.exe is on PATH."
.to_string(),
);
}
return Err("ssh was not found on PATH".to_string());
}
Ok(())
}
fn ssh_command(askpass: &Askpass, args: Vec<String>) -> Command {
let mut cmd = Command::new("ssh");
cmd.args(args);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.env("SSH_ASKPASS_REQUIRE", "force");
cmd.env("SSH_ASKPASS", &askpass.exe);
cmd.env("OPENCODE_SSH_ASKPASS_SOCKET", &askpass.socket);
if std::env::var_os("DISPLAY").is_none() {
cmd.env("DISPLAY", "1");
}
// keep behavior consistent even if ssh wants a tty.
cmd.env("TERM", "dumb");
cmd
}
fn ssh_spawn_bg(askpass: &Askpass, args: Vec<String>) -> Command {
let mut cmd = Command::new("ssh");
cmd.args(args);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::piped());
cmd.env("SSH_ASKPASS_REQUIRE", "force");
cmd.env("SSH_ASKPASS", &askpass.exe);
cmd.env("OPENCODE_SSH_ASKPASS_SOCKET", &askpass.socket);
if std::env::var_os("DISPLAY").is_none() {
cmd.env("DISPLAY", "1");
}
cmd.env("TERM", "dumb");
cmd
}
async fn ssh_output(askpass: &Askpass, args: Vec<String>) -> Result<String, String> {
let out = ssh_command(askpass, args)
.output()
.await
.map_err(|e| format!("Failed to run ssh: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let msg = stderr.trim();
if msg.is_empty() {
return Err("SSH command failed".to_string());
}
return Err(msg.to_string());
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn control_supported() -> bool {
cfg!(unix)
}
fn control_args(socket_path: Option<&Path>, mode: ControlMode) -> Vec<String> {
if !control_supported() {
return Vec::new();
}
let Some(socket_path) = socket_path else {
return Vec::new();
};
let mut args = Vec::new();
match mode {
ControlMode::Master => {
args.push("-o".into());
args.push("ControlMaster=yes".into());
args.push("-o".into());
args.push("ControlPersist=no".into());
}
ControlMode::Client => {
args.push("-o".into());
args.push("ControlMaster=no".into());
}
}
args.push("-o".into());
args.push(format!("ControlPath={}", socket_path.display()));
args
}
async fn wait_master_ready(askpass: &Askpass, spec: &Spec, socket_path: &Path) -> Result<(), String> {
let start = Instant::now();
loop {
if start.elapsed() > Duration::from_secs(30) {
return Err("Timed out waiting for SSH connection".to_string());
}
let res = ssh_command(
askpass,
[
control_args(Some(socket_path), ControlMode::Client),
vec!["-O".into(), "check".into(), spec.destination.clone()],
]
.concat(),
)
.output()
.await;
if let Ok(out) = res {
if out.status.success() {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn ensure_remote_opencode(
app: &AppHandle,
askpass: &Askpass,
spec: &Spec,
socket_path: Option<&Path>,
) -> Result<(), String> {
let version = app.package_info().version.to_string();
let installed = ssh_output(
askpass,
[
spec.args.clone(),
control_args(socket_path, ControlMode::Client),
vec![
spec.destination.clone(),
"cd; ~/.opencode/bin/opencode --version".into(),
],
]
.concat(),
)
.await
.ok()
.map(|v| v.trim().to_string());
match installed.as_deref() {
Some(version) => log(format!("Remote opencode detected: {version}")),
None => log("Remote opencode not found"),
}
if installed.as_deref() == Some(version.as_str()) {
return Ok(());
}
log("Starting remote install");
let cmd = format!(
"cd; bash -lc {}",
sh_quote(&format!(
"curl -fsSL https://opencode.ai/install | bash -s -- --version {version} --no-modify-path"
))
);
ssh_output(
askpass,
[
spec.args.clone(),
control_args(socket_path, ControlMode::Client),
vec![spec.destination.clone(), cmd],
]
.concat(),
)
.await
.map(|_| ())?;
log("Remote install finished");
Ok(())
}
async fn spawn_master(
askpass: &Askpass,
spec: &Spec,
socket_path: &Path,
) -> Result<Child, String> {
let mut child = ssh_spawn_bg(
askpass,
[
spec.args.clone(),
vec!["-N".into()],
control_args(Some(socket_path), ControlMode::Master),
vec![spec.destination.clone()],
]
.concat(),
)
.spawn()
.map_err(|e| format!("Failed to start ssh: {e}"))?;
if let Some(stderr) = child.stderr.take() {
tokio::spawn(async move {
let mut err = BufReader::new(stderr).lines();
while let Ok(Some(line)) = err.next_line().await {
if !line.trim().is_empty() {
log(format!("[master] {line}"));
}
}
});
}
Ok(child)
}
fn parse_listening_port(line: &str) -> Option<u16> {
let needle = "opencode server listening on http://";
let rest = line.trim();
let rest = rest.strip_prefix(needle)?;
let hostport = rest.split_whitespace().next().unwrap_or(rest);
let port = hostport.rsplit(':').next()?;
port.trim().parse().ok()
}
async fn spawn_remote_server(
askpass: &Askpass,
spec: &Spec,
socket_path: Option<&Path>,
password: &str,
) -> Result<(Child, u16), String> {
let cmd = format!(
"cd; env OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD={password} OPENCODE_CLIENT=desktop ~/.opencode/bin/opencode serve --hostname 127.0.0.1 --port 0"
);
let mut child = ssh_command(
askpass,
[
spec.args.clone(),
control_args(socket_path, ControlMode::Client),
vec![spec.destination.clone(), cmd],
]
.concat(),
)
.spawn()
.map_err(|e| format!("Failed to start remote server: {e}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "Failed to capture remote server stdout".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "Failed to capture remote server stderr".to_string())?;
let (tx, mut rx) = tokio::sync::mpsc::channel::<u16>(1);
tokio::spawn(async move {
let mut out = BufReader::new(stdout).lines();
while let Ok(Some(line)) = out.next_line().await {
if !line.trim().is_empty() {
log(format!("[server] {line}"));
}
if let Some(port) = parse_listening_port(&line) {
let _ = tx.try_send(port);
}
}
});
tokio::spawn(async move {
let mut err = BufReader::new(stderr).lines();
while let Ok(Some(_line)) = err.next_line().await {
if !_line.trim().is_empty() {
log(format!("[server] {_line}"));
}
}
});
let port = tokio::time::timeout(Duration::from_secs(30), rx.recv())
.await
.map_err(|_| "Timed out waiting for remote server to start".to_string())?
.ok_or_else(|| "Remote server exited before becoming ready".to_string())?;
Ok((child, port))
}
async fn spawn_forward(
_app: &AppHandle,
askpass: &Askpass,
spec: &Spec,
socket_path: Option<&Path>,
local_port: u16,
remote_port: u16,
) -> Result<Child, String> {
let forward = format!("127.0.0.1:{local_port}:127.0.0.1:{remote_port}");
let mut child = ssh_spawn_bg(
askpass,
[
spec.args.clone(),
vec![
"-N".into(),
"-L".into(),
forward,
"-o".into(),
"ExitOnForwardFailure=yes".into(),
],
control_args(socket_path, ControlMode::Client),
vec![spec.destination.clone()],
]
.concat(),
)
.spawn()
.map_err(|e| format!("Failed to start port forward: {e}"))?;
if let Some(stderr) = child.stderr.take() {
tokio::spawn(async move {
let mut err = BufReader::new(stderr).lines();
while let Ok(Some(line)) = err.next_line().await {
if !line.trim().is_empty() {
log(format!("[forward] {line}"));
}
}
});
}
Ok(child)
}
async fn disconnect_session(mut session: SshSession) {
let _ = session.forward.kill().await;
let _ = session.server.kill().await;
if let Some(mut master) = session.master {
let _ = master.kill().await;
}
session.askpass_task.abort();
let _ = std::fs::remove_dir_all(session.dir);
}
async fn read_prompt<S: AsyncReadExt + Unpin>(stream: &mut S) -> Result<String, String> {
let mut len_buf = [0u8; 4];
stream
.read_exact(&mut len_buf)
.await
.map_err(|e| format!("Failed to read prompt length: {e}"))?;
let len = u32::from_be_bytes(len_buf) as usize;
if len > 64 * 1024 {
return Err("Askpass prompt too large".to_string());
}
let mut buf = vec![0u8; len];
stream
.read_exact(&mut buf)
.await
.map_err(|e| format!("Failed to read prompt: {e}"))?;
let prompt = String::from_utf8(buf).map_err(|_| "Askpass prompt was not UTF-8".to_string())?;
Ok(prompt)
}
async fn write_reply<S: AsyncWriteExt + Unpin>(stream: &mut S, value: &str) -> Result<(), String> {
let bytes = value.as_bytes();
let len = u32::try_from(bytes.len()).map_err(|_| "Askpass reply too large".to_string())?;
stream
.write_all(&len.to_be_bytes())
.await
.map_err(|e| format!("Failed to write reply length: {e}"))?;
stream
.write_all(bytes)
.await
.map_err(|e| format!("Failed to write reply: {e}"))?;
Ok(())
}
async fn spawn_askpass_server(
app: AppHandle,
dir: &Path,
) -> Result<(tokio::task::JoinHandle<()>, String), String> {
#[cfg(unix)]
{
let socket = dir.join("askpass.sock");
let listener = UnixListener::bind(&socket)
.map_err(|e| format!("Failed to bind askpass socket {}: {e}", socket.display()))?;
let location = socket.to_string_lossy().to_string();
log(format!("Askpass listening on {}", socket.display()));
let task = tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let app = app.clone();
tokio::spawn(async move {
let prompt = match read_prompt(&mut stream).await {
Ok(v) => v,
Err(_) => return,
};
log(format!("Prompt received: {}", prompt.replace('\n', "\\n")));
let id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = oneshot::channel::<String>();
{
let state = app.state::<SshState>();
state.prompts.lock().await.insert(id.clone(), tx);
}
match app.emit(
"ssh_prompt",
SshPrompt {
id: id.clone(),
prompt,
},
) {
Ok(()) => log(format!("Prompt emitted: {id}")),
Err(e) => log(format!("Prompt emit failed: {id}: {e}")),
};
let value = tokio::time::timeout(Duration::from_secs(120), rx)
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default();
if value.is_empty() {
log(format!("Prompt reply empty/timeout: {id}"));
} else {
log(format!("Prompt reply received: {id}"));
}
{
let state = app.state::<SshState>();
state.prompts.lock().await.remove(&id);
}
let _ = write_reply(&mut stream, &value).await;
});
}
});
return Ok((task, location));
}
#[cfg(not(unix))]
{
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind askpass listener: {e}"))?;
let addr = listener
.local_addr()
.map_err(|e| format!("Failed to read askpass address: {e}"))?;
let location = format!("tcp:{addr}");
log(format!("Askpass listening on {addr}"));
let task = tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let app = app.clone();
tokio::spawn(async move {
let prompt = match read_prompt(&mut stream).await {
Ok(v) => v,
Err(_) => return,
};
log(format!("Prompt received: {}", prompt.replace('\n', "\\n")));
let id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = oneshot::channel::<String>();
{
let state = app.state::<SshState>();
state.prompts.lock().await.insert(id.clone(), tx);
}
match app.emit(
"ssh_prompt",
SshPrompt {
id: id.clone(),
prompt,
},
) {
Ok(()) => log(format!("Prompt emitted: {id}")),
Err(e) => log(format!("Prompt emit failed: {id}: {e}")),
};
let value = tokio::time::timeout(Duration::from_secs(120), rx)
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default();
if value.is_empty() {
log(format!("Prompt reply empty/timeout: {id}"));
} else {
log(format!("Prompt reply received: {id}"));
}
{
let state = app.state::<SshState>();
state.prompts.lock().await.remove(&id);
}
let _ = write_reply(&mut stream, &value).await;
});
}
});
return Ok((task, location));
}
}
#[tauri::command]
#[specta::specta]
pub async fn ssh_prompt_reply(app: AppHandle, id: String, value: String) -> Result<(), String> {
log(format!(
"Prompt reply from UI: {id} ({} chars)",
value.len()
));
let state = app.state::<SshState>();
let tx = state.prompts.lock().await.remove(&id);
let Some(tx) = tx else {
return Ok(());
};
let _ = tx.send(value);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn ssh_disconnect(app: AppHandle, key: String) -> Result<(), String> {
let state = app.state::<SshState>();
let session = {
let mut lock = state.session.lock().await;
if lock.as_ref().is_some_and(|s| s.key == key) {
lock.take()
} else {
None
}
};
if let Some(session) = session {
tokio::spawn(async move {
disconnect_session(session).await;
});
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn ssh_connect(app: AppHandle, command: String) -> Result<SshConnectData, String> {
async {
ensure_ssh_available().await?;
let spec = parse_ssh_command(&command)?;
log(format!("Connect requested: {}", spec.destination));
// Disconnect any existing session.
{
let state = app.state::<SshState>();
if let Some(session) = state.session.lock().await.take() {
disconnect_session(session).await;
}
}
let key = uuid::Uuid::new_v4().to_string();
let password = uuid::Uuid::new_v4().to_string();
let local_port = free_port();
let url = format!("http://127.0.0.1:{local_port}");
// Unix domain sockets (and OpenSSH ControlPath) have strict length limits on macOS.
// Avoid long per-user temp dirs like /var/folders/... by using /tmp.
let dir = if control_supported() {
PathBuf::from("/tmp").join(format!("opencode-ssh-{key}"))
} else {
std::env::temp_dir().join(format!("opencode-ssh-{key}"))
};
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create temp dir: {e}"))?;
let socket_path = control_supported().then(|| dir.join("ssh.sock"));
let (askpass_task, askpass_socket) = spawn_askpass_server(app.clone(), &dir).await?;
let askpass = Askpass {
socket: askpass_socket,
exe: exe_path(&app)?,
};
log(format!("Session dir: {}", dir.display()));
if let Some(path) = socket_path.as_ref() {
log(format!("ControlPath: {}", path.display()));
}
log(format!("Askpass socket: {}", askpass.socket));
let master = if let Some(path) = socket_path.as_ref() {
log("Starting SSH master");
let master = spawn_master(&askpass, &spec, path).await?;
log("Waiting for master ready");
wait_master_ready(&askpass, &spec, path).await?;
log("Master ready");
Some(master)
} else {
None
};
log("Ensuring remote opencode");
ensure_remote_opencode(&app, &askpass, &spec, socket_path.as_deref()).await?;
log("Remote opencode ready");
log("Starting remote opencode server");
let (server_child, remote_port) =
spawn_remote_server(&askpass, &spec, socket_path.as_deref(), &password).await?;
log(format!("Remote server port: {remote_port}"));
log(format!("Starting port forward to {url}"));
let forward_child = spawn_forward(
&app,
&askpass,
&spec,
socket_path.as_deref(),
local_port,
remote_port,
)
.await?;
log("Waiting for forwarded health");
let start = Instant::now();
loop {
if start.elapsed() > Duration::from_secs(30) {
return Err("Timed out waiting for forwarded server health".to_string());
}
if server::check_health(&url, Some(&password)).await {
log("Forwarded health OK");
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let session = SshSession {
key: key.clone(),
destination: spec.destination.clone(),
dir: dir.clone(),
socket_path,
askpass_task,
master,
forward: forward_child,
server: server_child,
};
app.state::<SshState>()
.session
.lock()
.await
.replace(session);
Ok(SshConnectData {
key,
url,
password,
destination: spec.destination,
})
}
.await
}
pub fn shutdown(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let state = app.state::<SshState>();
if let Some(session) = state.session.lock().await.take() {
disconnect_session(session).await;
}
});
}

View File

@@ -10,10 +10,11 @@ export const commands = {
awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
sshConnect: (command: string) => __TAURI_INVOKE<SshConnectData>("ssh_connect", { command }),
sshDisconnect: (key: string) => __TAURI_INVOKE<null>("ssh_disconnect", { key }),
sshPromptReply: (id: string, value: string) => __TAURI_INVOKE<null>("ssh_prompt_reply", { id, value }),
};
/** Events */
@@ -24,8 +25,6 @@ export const events = {
/* Types */
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" };
export type LinuxDisplayBackend = "wayland" | "auto";
export type LoadingWindowComplete = null;
export type ServerReadyData = {
@@ -33,6 +32,13 @@ export type ServerReadyData = {
password: string | null,
};
export type SshConnectData = {
key: string,
url: string,
password: string,
destination: string,
};
/* Tauri Specta runtime */
function makeEvent<T>(name: string) {
const base = {

View File

@@ -1,14 +1,7 @@
// @refresh reload
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
import {
AppBaseProviders,
AppInterface,
PlatformProvider,
Platform,
DisplayBackend,
useCommand,
} from "@opencode-ai/app"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
@@ -16,22 +9,27 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Splash } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup, createEffect } from "solid-js"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
import { createStore } from "solid-js/store"
import { listen } from "@tauri-apps/api/event"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Button } from "@opencode-ai/ui/button"
import { UPDATER_ENABLED } from "./updater"
import { initI18n, t } from "./i18n"
import pkg from "../package.json"
import "./styles.css"
import { commands, InitStep } from "./bindings"
import { Channel } from "@tauri-apps/api/core"
import { Channel, invoke } from "@tauri-apps/api/core"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -41,6 +39,46 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
void initI18n()
const ssh = new Map<string, string>()
const auth = new Map<string, string>()
let base = null as string | null
type SshPrompt = { id: string; prompt: string }
const sshPromptEvent = "opencode:ssh-prompt"
const sshPrompts: SshPrompt[] = []
type DesktopPlatform = Platform & {
serverKey: (url: string) => string
isServerLocal: (url: string) => boolean
sshConnect: (command: string) => Promise<{ url: string; key: string; password: string | null }>
sshDisconnect: (key: string) => Promise<void>
wsAuth: (url: string) => { username: string; password: string } | null
}
void listen<SshPrompt>("ssh_prompt", (event) => {
sshPrompts.push(event.payload)
window.dispatchEvent(new CustomEvent(sshPromptEvent))
}).catch((err) => {
console.error("Failed to listen for ssh_prompt", err)
})
const isConfirmPrompt = (prompt: string) => {
const text = prompt.toLowerCase()
return text.includes("yes/no") || text.includes("continue connecting")
}
const isMaskedPrompt = (prompt: string) => {
const text = prompt.toLowerCase()
return (
text.includes("password") ||
text.includes("passphrase") ||
text.includes("verification code") ||
text.includes("one-time") ||
text.includes("otp")
)
}
let update: Update | null = null
const deepLinkEvent = "opencode:deep-link"
@@ -59,7 +97,10 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
}
const createPlatform = (password: Accessor<string | null>): Platform => ({
const createPlatform = (
password: Accessor<string | null>,
sshState: { get: Accessor<boolean>; set: (value: boolean) => void },
): DesktopPlatform => ({
platform: "desktop",
os: (() => {
const type = ostype()
@@ -283,6 +324,8 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
},
restart: async () => {
const keys = Array.from(new Set(ssh.values()))
await Promise.all(keys.map((key) => invoke<void>("ssh_disconnect", { key }).catch(() => undefined)))
await commands.killSidecar().catch(() => undefined)
await relaunch()
},
@@ -318,21 +361,51 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
},
fetch: (input, init) => {
const pw = password()
if (typeof input === "string" && input.startsWith("/") && base) {
input = base + input
}
const origin = (() => {
try {
const url = input instanceof Request ? input.url : String(input)
return new URL(url).origin
} catch {
return null
}
})()
const pw = origin ? (auth.get(origin) ?? null) : password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
const logError = async (url: string, res: Response) => {
if (res.ok) return
// keep it minimal; enough to debug auth/baseUrl issues
const text = await res
.clone()
.text()
.catch(() => "")
console.error("fetch failed", { url, status: res.status, statusText: res.statusText, body: text.slice(0, 400) })
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
return tauriFetch(input).then((res) => {
void logError(input.url, res)
return res
})
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
const url = String(input)
return tauriFetch(url, {
...(init as any),
headers: headers,
}).then((res) => {
void logError(url, res)
return res
})
}
},
@@ -346,13 +419,66 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
await commands.setDefaultServerUrl(url)
},
getDisplayBackend: async () => {
const result = await invoke<DisplayBackend | null>("get_display_backend").catch(() => null)
return result
serverKey: (url) => {
const origin = (() => {
try {
return new URL(url).origin
} catch {
return ""
}
})()
const key = origin ? ssh.get(origin) : undefined
if (key) return `ssh:${key}`
if (origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]")) return "local"
return url
},
setDisplayBackend: async (backend) => {
await invoke("set_display_backend", { backend }).catch(() => undefined)
isServerLocal: (url) => {
const origin = (() => {
try {
return new URL(url).origin
} catch {
return null
}
})()
if (origin && ssh.has(origin)) return false
if (!origin) return false
return origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]")
},
sshConnect: async (command) => {
sshState.set(true)
try {
const result = await invoke<{ key: string; url: string; password: string; destination: string }>("ssh_connect", {
command,
})
const origin = new URL(result.url).origin
ssh.set(origin, result.key)
auth.set(origin, result.password)
return { url: result.url, key: result.key, password: result.password }
} finally {
sshState.set(false)
}
},
sshDisconnect: async (key) => {
await invoke<void>("ssh_disconnect", { key })
for (const [origin, k] of ssh.entries()) {
if (k !== key) continue
ssh.delete(origin)
auth.delete(origin)
}
},
wsAuth: (url) => {
try {
const origin = new URL(url).origin
const pw = auth.get(origin) ?? password()
if (!pw) return null
return { username: "opencode", password: pw }
} catch {
return null
}
},
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
@@ -395,7 +521,143 @@ void listenForDeepLinks()
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
const [sshConnecting, setSshConnecting] = createSignal(false)
const platform = createPlatform(() => serverPassword(), { get: sshConnecting, set: setSshConnecting })
function SshPromptDialog(props: {
prompt: Accessor<string>
pending: Accessor<boolean>
onSubmit: (value: string) => void
onCancel: () => void
}) {
const confirm = () => isConfirmPrompt(props.prompt())
const masked = () => isMaskedPrompt(props.prompt())
const [value, setValue] = createSignal("")
return (
<Dialog title="SSH" fit>
<div class="flex flex-col gap-3 px-3 pb-3">
<div class="text-14-regular text-text-base whitespace-pre-wrap px-1">{props.prompt()}</div>
<Show when={!confirm()}>
<TextField
type={masked() ? "password" : "text"}
hideLabel
placeholder={masked() ? "Password" : "Response"}
value={value()}
autofocus
disabled={props.pending()}
onChange={(v) => setValue(v)}
onKeyDown={(event: KeyboardEvent) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.onCancel()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
props.onSubmit(value())
}}
/>
</Show>
<div class="flex items-center justify-end gap-2">
<Show
when={confirm()}
fallback={
<>
<Button variant="secondary" onClick={props.onCancel} disabled={props.pending()}>
Cancel
</Button>
<Button variant="primary" onClick={() => props.onSubmit(value())} disabled={props.pending()}>
{props.pending() ? "Connecting..." : "Continue"}
</Button>
</>
}
>
<Button variant="secondary" onClick={() => props.onSubmit("no")} disabled={props.pending()}>
No
</Button>
<Button variant="primary" onClick={() => props.onSubmit("yes")} disabled={props.pending()}>
{props.pending() ? "Connecting..." : "Yes"}
</Button>
</Show>
</div>
</div>
</Dialog>
)
}
function SshPromptHandler(props: { connecting: Accessor<boolean> }) {
const dialog = useDialog()
const [store, setStore] = createStore({
prompt: null as SshPrompt | null,
pending: false,
open: false,
})
const open = () => {
if (store.open) return
setStore("open", true)
dialog.show(
() => (
<SshPromptDialog
prompt={() => store.prompt?.prompt ?? ""}
pending={() => store.pending}
onSubmit={async (value) => {
const current = store.prompt
if (!current) return
setStore({ pending: true })
await invoke<void>("ssh_prompt_reply", { id: current.id, value }).catch((err) => {
console.error("Failed to send ssh_prompt_reply", err)
})
}}
onCancel={async () => {
const current = store.prompt
setStore({ pending: true })
if (current) {
await invoke<void>("ssh_prompt_reply", { id: current.id, value: "" }).catch((err) => {
console.error("Failed to send ssh_prompt_reply", err)
})
}
close()
}}
/>
),
() => close(),
)
}
const close = () => {
if (!store.open) return
dialog.close()
setStore({ open: false, pending: false, prompt: null })
}
const showNext = () => {
const next = sshPrompts.shift()
if (!next) return
setStore({ prompt: next, pending: false })
open()
}
onMount(() => {
const onPrompt = () => showNext()
window.addEventListener(sshPromptEvent, onPrompt)
showNext()
onCleanup(() => {
window.removeEventListener(sshPromptEvent, onPrompt)
})
})
createEffect(() => {
if (props.connecting()) return
close()
})
return null
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -418,6 +680,15 @@ render(() => {
<ServerGate>
{(data) => {
setServerPassword(data().password)
try {
const origin = new URL(data().url).origin
base = origin
const pw = data().password
if (pw) auth.set(origin, pw)
if (!pw) auth.delete(origin)
} catch {
// ignore
}
window.__OPENCODE__ ??= {}
window.__OPENCODE__.serverPassword = data().password ?? undefined
@@ -430,9 +701,12 @@ render(() => {
}
return (
<AppInterface defaultUrl={data().url} isSidecar>
<Inner />
</AppInterface>
<>
<AppInterface defaultUrl={data().url} isSidecar>
<Inner />
<SshPromptHandler connecting={() => sshConnecting()} />
</AppInterface>
</>
)
}}
</ServerGate>

View File

@@ -228,8 +228,8 @@ export namespace ACP {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const file = Bun.file(filepath)
const content = (await file.exists()) ? await file.text() : ""
const content = await Bun.file(filepath).text()
const newContent = getNewContent(content, diff)
if (newContent) {

View File

@@ -26,67 +26,82 @@ export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const connected = createMemo(() => new Set(sync.data.provider_next.connected))
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
map((provider) => {
const isConnected = connected().has(provider.id)
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
footer: isConnected ? "Connected" : undefined,
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
},
}
}),
)
})
return options
@@ -109,6 +124,7 @@ function AutoMethod(props: AutoMethodProps) {
const dialog = useDialog()
const sync = useSync()
const toast = useToast()
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
@@ -139,9 +155,16 @@ function AutoMethod(props: AutoMethodProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box gap={1}>
<Link href={props.authorization.url} fg={theme.primary} />

View File

@@ -3,7 +3,8 @@ import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show, createMemo } from "solid-js"
import { For, Match, Switch, Show, createMemo, createSignal } from "solid-js"
import { Installation } from "@/installation"
export type DialogStatusProps = {}
@@ -11,6 +12,7 @@ export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
const dialog = useDialog()
const [hover, setHover] = createSignal(false)
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
@@ -45,10 +47,18 @@ export function DialogStatus() {
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<text fg={theme.textMuted}>OpenCode v{Installation.VERSION}</text>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
<box>
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>

View File

@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Installation } from "@/installation"
import { useTerminalDimensions } from "@opentui/solid"
const Title = (props: { session: Accessor<Session> }) => {
@@ -86,7 +87,10 @@ export function Header() {
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<ContextInfo context={context} cost={cost} />
<box flexDirection="row" gap={1} flexShrink={0}>
<ContextInfo context={context} cost={cost} />
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
</box>
<box flexDirection="row" gap={2}>
<box
@@ -125,7 +129,10 @@ export function Header() {
<Match when={true}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
<box flexDirection="row" gap={1} flexShrink={0}>
<ContextInfo context={context} cost={cost} />
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
</box>
</Match>
</Switch>

View File

@@ -17,7 +17,7 @@ import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import {
BoxRenderable,
ScrollBoxRenderable,
@@ -1099,6 +1099,9 @@ export function Session() {
sessionID={route.sessionID}
/>
</box>
<Show when={!sidebarVisible() || !wide()}>
<Footer />
</Show>
</Show>
<Toast />
</box>
@@ -1152,8 +1155,7 @@ function UserMessage(props: {
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => local.agent.color(props.message.agent))
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
@@ -1215,7 +1217,7 @@ function UserMessage(props: {
}
>
<text fg={theme.textMuted}>
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</text>
</Show>
</box>

View File

@@ -2,6 +2,7 @@ import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { createSignal } from "solid-js"
export type DialogAlertProps = {
title: string
@@ -12,6 +13,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "return") {
@@ -25,9 +27,16 @@ export function DialogAlert(props: DialogAlertProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>

View File

@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { createSignal, For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale"
@@ -16,6 +16,7 @@ export type DialogConfirmProps = {
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
@@ -37,9 +38,16 @@ export function DialogConfirm(props: DialogConfirmProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>

View File

@@ -2,7 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show, type JSX } from "solid-js"
import { createSignal, onMount, Show, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogExportOptionsProps = {
@@ -25,6 +25,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
const [hover, setHover] = createSignal(false)
const [store, setStore] = createStore({
thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails,
@@ -80,9 +81,16 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Export Options
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box gap={1}>
<box>

View File

@@ -3,11 +3,13 @@ import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { createSignal } from "solid-js"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
const keybind = useKeybind()
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
@@ -21,9 +23,16 @@ export function DialogHelp() {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Help
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc/enter
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc/enter</text>
</box>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>

View File

@@ -1,7 +1,7 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount, type JSX } from "solid-js"
import { createSignal, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogPromptProps = {
@@ -17,6 +17,7 @@ export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "return") {
@@ -39,9 +40,16 @@ export function DialogPrompt(props: DialogPromptProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box gap={1}>
{props.description}

View File

@@ -1,7 +1,7 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
@@ -49,6 +49,7 @@ export type DialogSelectRef<T> = {
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const [store, setStore] = createStore({
selected: 0,
filter: "",
@@ -226,9 +227,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box paddingTop={1}>
<input

View File

@@ -148,16 +148,6 @@ export namespace Installation {
break
case "brew": {
const formula = await getBrewFormula()
if (formula.includes("/")) {
cmd =
$`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
{
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
},
)
break
}
cmd = $`brew upgrade ${formula}`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
@@ -198,19 +188,14 @@ export namespace Installation {
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
return version
if (formula === "opencode") {
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.versions.stable)
}
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.versions.stable)
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {

View File

@@ -321,18 +321,7 @@ export namespace SessionPrompt {
history: msgs,
})
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => {
if (Provider.ModelNotFoundError.isInstance(e)) {
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
}).toObject(),
})
}
throw e
})
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
const task = tasks.pop()
// pending subtask
@@ -854,11 +843,14 @@ export namespace SessionPrompt {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const full =
!input.variant && agent.variant
? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined)
: undefined
const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined)
const variant =
input.variant ??
(agent.variant &&
agent.model &&
model.providerID === agent.model.providerID &&
model.modelID === agent.model.modelID
? agent.variant
: undefined)
const info: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),

View File

@@ -6,63 +6,55 @@ import { tmpdir } from "../fixture/fixture"
describe("session.prompt agent variant", () => {
test("applies agent variant only when using agent model", async () => {
const prev = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = "test-openai-key"
try {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
await Session.remove(session.id)
},
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = prev
}
await Session.remove(session.id)
},
})
})
})

View File

@@ -1 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g clip-path="url(#prefix__clip0_5_17)"><rect width="512" height="512" rx="122" fill="#000"/><g clip-path="url(#prefix__clip1_5_17)"><mask id="prefix__a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="85" y="89" width="343" height="334"><path d="M85 89h343v334H85V89z" fill="#fff"/></mask><g mask="url(#prefix__a)"><path d="M255.428 423l148.991-83.5L255.428 256l-148.99 83.5 148.99 83.5z" fill="url(#prefix__paint0_linear_5_17)"/><path d="M404.419 339.5v-167L255.428 89v167l148.991 83.5z" fill="url(#prefix__paint1_linear_5_17)"/><path d="M255.428 89l-148.99 83.5v167l148.99-83.5V89z" fill="url(#prefix__paint2_linear_5_17)"/><path d="M404.419 172.5L255.428 423V256l148.991-83.5z" fill="#E4E4E4"/><path d="M404.419 172.5L255.428 256l-148.99-83.5h297.981z" fill="#fff"/></g></g></g><defs><linearGradient id="prefix__paint0_linear_5_17" x1="255.428" y1="256" x2="255.428" y2="423" gradientUnits="userSpaceOnUse"><stop offset=".16" stop-color="#fff" stop-opacity=".39"/><stop offset=".658" stop-color="#fff" stop-opacity=".8"/></linearGradient><linearGradient id="prefix__paint1_linear_5_17" x1="404.419" y1="173.015" x2="257.482" y2="261.497" gradientUnits="userSpaceOnUse"><stop offset=".182" stop-color="#fff" stop-opacity=".31"/><stop offset=".715" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="prefix__paint2_linear_5_17" x1="255.428" y1="89" x2="112.292" y2="342.802" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".6"/><stop offset=".667" stop-color="#fff" stop-opacity=".22"/></linearGradient><clipPath id="prefix__clip0_5_17"><path fill="#fff" d="M0 0h512v512H0z"/></clipPath><clipPath id="prefix__clip1_5_17"><path fill="#fff" transform="translate(85 89)" d="M0 0h343v334H0z"/></clipPath></defs></svg>
<?xml version="1.0" encoding="UTF-8"?><svg id="cursor_light__Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09"><!--Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9)--><defs><style>.cursor_light__st0{fill:#26251e}</style></defs><path class="cursor_light__st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 513 KiB

View File

@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96">
<g clip-path="url(#zed_logo-dark-a)">
<path
fill="#fff"
fill-rule="evenodd"
d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z"
clip-rule="evenodd"
/>
</g>
<defs>
<clipPath id="zed_logo-dark-a">
<path fill="#fff" d="M0 0h96v96H0z" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 746 B

View File

@@ -1,15 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96">
<g clip-path="url(#zed_logo-a)">
<path
fill="#000"
fill-rule="evenodd"
d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z"
clip-rule="evenodd"
/>
</g>
<defs>
<clipPath id="zed_logo-a">
<path fill="#fff" d="M0 0h96v96H0z" />
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><g clip-path="url(#zed_light__a)"><path fill="currentColor" fill-rule="evenodd" d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z" clip-rule="evenodd"/></g><defs><clipPath id="zed_light__a"><path fill="#fff" d="M0 0h96v96H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 682 B

View File

@@ -1,5 +1,9 @@
img[data-component="app-icon"] {
display: block;
box-sizing: border-box;
padding: 2px;
border-radius: 0.125rem;
background: var(--smoke-light-2);
border: 1px solid var(--smoke-light-alpha-4);
object-fit: contain;
}

View File

@@ -1,5 +1,5 @@
import type { Component, ComponentProps } from "solid-js"
import { createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { splitProps } from "solid-js"
import type { IconName } from "./app-icons/types"
import androidStudio from "../assets/icons/app/android-studio.svg"
@@ -15,7 +15,6 @@ import textmate from "../assets/icons/app/textmate.png"
import vscode from "../assets/icons/app/vscode.svg"
import xcode from "../assets/icons/app/xcode.png"
import zed from "../assets/icons/app/zed.svg"
import zedDark from "../assets/icons/app/zed-dark.svg"
import sublimetext from "../assets/icons/app/sublimetext.svg"
const icons = {
@@ -35,43 +34,17 @@ const icons = {
"sublime-text": sublimetext,
} satisfies Record<IconName, string>
const themed: Partial<Record<IconName, { light: string; dark: string }>> = {
zed: {
light: zed,
dark: zedDark,
},
}
const scheme = () => {
if (typeof document !== "object") return "light" as const
if (document.documentElement.dataset.colorScheme === "dark") return "dark" as const
return "light" as const
}
export type AppIconProps = Omit<ComponentProps<"img">, "src"> & {
id: IconName
}
export const AppIcon: Component<AppIconProps> = (props) => {
const [local, rest] = splitProps(props, ["id", "class", "classList", "alt", "draggable"])
const [mode, setMode] = createSignal(scheme())
onMount(() => {
const sync = () => setMode(scheme())
const observer = new MutationObserver(sync)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-color-scheme"],
})
sync()
onCleanup(() => observer.disconnect())
})
return (
<img
data-component="app-icon"
{...rest}
src={themed[local.id]?.[mode()] ?? icons[local.id]}
src={icons[local.id]}
alt={local.alt ?? ""}
draggable={local.draggable ?? false}
classList={{

View File

@@ -79,9 +79,9 @@
background-color: var(--surface-hover);
}
&:not([data-readonly]) [data-slot="checkbox-checkbox-input"]:focus-visible + [data-slot="checkbox-checkbox-control"] {
&:focus-within:not([data-readonly]) [data-slot="checkbox-checkbox-control"] {
border-color: var(--border-focus);
box-shadow: var(--shadow-xs-border-focus);
box-shadow: 0 0 0 2px var(--surface-focus);
}
&[data-checked] [data-slot="checkbox-checkbox-control"],

View File

@@ -32,7 +32,6 @@
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
cursor: not-allowed;
@@ -71,7 +70,6 @@
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
cursor: not-allowed;

View File

@@ -538,7 +538,11 @@ const toOpenVariant = (icon: IconName): IconName => {
return icon
}
const basenameOf = (p: string) => p.split("\\").join("/").split("/").filter(Boolean).pop() ?? ""
const basenameOf = (p: string) =>
p
.replace(/[/\\]+$/, "")
.split(/[\\/]/)
.pop() ?? ""
const folderNameVariants = (name: string) => {
const n = name.toLowerCase()

View File

@@ -82,7 +82,7 @@
box-shadow: var(--shadow-xs-border-base);
}
&:not([data-expanded]):not(:focus-visible):focus {
&:not([data-expanded]):focus {
background-color: transparent;
box-shadow: none;
}

View File

@@ -131,11 +131,6 @@ function isAttachment(part: PartType | undefined) {
return mime.startsWith("image/") || mime === "application/pdf"
}
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
}
function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
@@ -145,7 +140,7 @@ function AssistantMessageItem(props: {
}) {
const data = useData()
const emptyParts: PartType[] = []
const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts))
const msgParts = createMemo(() => data.store.part[props.message.id] ?? emptyParts)
const lastTextPart = createMemo(() => {
const parts = msgParts()
for (let i = parts.length - 1; i >= 0; i--) {
@@ -211,7 +206,7 @@ export function SessionTurn(
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages)
const messageIndex = createMemo(() => {
const messages = allMessages() ?? emptyMessages
@@ -253,7 +248,7 @@ export function SessionTurn(
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
return list(data.store.part?.[msg.id], emptyParts)
return data.store.part[msg.id] ?? emptyParts
})
const attachmentParts = createMemo(() => {
@@ -304,7 +299,7 @@ export function SessionTurn(
const lastTextPart = createMemo(() => {
const msgs = assistantMessages()
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
const msgParts = data.store.part[msgs[mi].id] ?? emptyParts
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part?.type === "text") return part as TextPart
@@ -315,7 +310,8 @@ export function SessionTurn(
const hasSteps = createMemo(() => {
for (const m of assistantMessages()) {
const msgParts = list(data.store.part?.[m.id], emptyParts)
const msgParts = data.store.part[m.id]
if (!msgParts) continue
for (const p of msgParts) {
if (p?.type === "tool") return true
}
@@ -323,10 +319,10 @@ export function SessionTurn(
return false
})
const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions))
const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions)
const nextPermission = createMemo(() => permissions()[0])
const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions))
const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions)
const nextQuestion = createMemo(() => questions()[0])
const hidden = createMemo(() => {
@@ -345,7 +341,7 @@ export function SessionTurn(
const result: { part: ToolPart; message: AssistantMessage }[] = []
for (const msg of assistantMessages()) {
const parts = list(data.store.part?.[msg.id], emptyParts)
const parts = data.store.part[msg.id] ?? emptyParts
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
@@ -369,7 +365,7 @@ export function SessionTurn(
const msgs = assistantMessages()
if (msgs.length !== 1) return
const msgParts = list(data.store.part?.[msgs[0].id], emptyParts)
const msgParts = data.store.part[msgs[0].id] ?? emptyParts
if (msgParts.length !== 1) return
const assistantPart = msgParts[0]
@@ -384,7 +380,7 @@ export function SessionTurn(
let currentTask: ToolPart | undefined
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
const msgParts = data.store.part[msgs[mi].id] ?? emptyParts
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (!part) continue
@@ -411,12 +407,12 @@ export function SessionTurn(
: undefined
if (taskSessionId) {
const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages)
const taskMessages = data.store.message[taskSessionId] ?? emptyMessages
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
const msg = taskMessages[mi]
if (!msg || msg.role !== "assistant") continue
const msgParts = list(data.store.part?.[msg.id], emptyParts)
const msgParts = data.store.part[msg.id] ?? emptyParts
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part) return computeStatusFromPart(part, i18n.t)

View File

@@ -86,9 +86,9 @@
background-color: var(--surface-hover);
}
&:not([data-readonly]) [data-slot="switch-input"]:focus-visible ~ [data-slot="switch-control"] {
&:focus-within:not([data-readonly]) [data-slot="switch-control"] {
border-color: var(--border-focus);
box-shadow: var(--shadow-xs-border-focus);
box-shadow: 0 0 0 2px var(--surface-focus);
}
&[data-checked] [data-slot="switch-control"] {

View File

@@ -429,11 +429,6 @@
background-color: var(--surface-raised-base-hover);
}
&:has([data-slot="tabs-trigger"]:focus-visible) {
background-color: var(--surface-raised-base-hover);
box-shadow: var(--shadow-xs-border-focus);
}
&:has([data-selected]) {
background-color: var(--surface-raised-base-active);
color: var(--text-strong);

View File

@@ -1,5 +1,5 @@
import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
import { createSignal, Match, splitProps, Switch, type JSX } from "solid-js"
import { children, createSignal, Match, onMount, splitProps, Switch, type JSX } from "solid-js"
import type { ComponentProps } from "solid-js"
export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
@@ -40,16 +40,32 @@ export function Tooltip(props: TooltipProps) {
"contentStyle",
"inactive",
"forceOpen",
"value",
])
const c = children(() => local.children)
onMount(() => {
const childElements = c()
if (childElements instanceof HTMLElement) {
childElements.addEventListener("focusin", () => setOpen(true))
childElements.addEventListener("focusout", () => setOpen(false))
} else if (Array.isArray(childElements)) {
for (const child of childElements) {
if (child instanceof HTMLElement) {
child.addEventListener("focusin", () => setOpen(true))
child.addEventListener("focusout", () => setOpen(false))
}
}
}
})
return (
<Switch>
<Match when={local.inactive}>{local.children}</Match>
<Match when={true}>
<KobalteTooltip gutter={4} {...others} open={local.forceOpen || open()} onOpenChange={setOpen}>
<KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}>
{local.children}
{c()}
</KobalteTooltip.Trigger>
<KobalteTooltip.Portal>
<KobalteTooltip.Content
@@ -59,7 +75,7 @@ export function Tooltip(props: TooltipProps) {
class={local.contentClass}
style={local.contentStyle}
>
{local.value}
{others.value}
{/* <KobalteTooltip.Arrow data-slot="tooltip-arrow" /> */}
</KobalteTooltip.Content>
</KobalteTooltip.Portal>

View File

@@ -0,0 +1,231 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkWorldBg": "#0B0B3B",
"darkWorldDeep": "#050520",
"darkWorldPanel": "#151555",
"krisBlue": "#6A7BC4",
"krisCyan": "#75FBED",
"krisIce": "#C7E3F2",
"susiePurple": "#5B209D",
"susieMagenta": "#A017D0",
"susiePink": "#F983D8",
"ralseiGreen": "#33A56C",
"ralseiTeal": "#40E4D4",
"noelleRose": "#DC8998",
"noelleRed": "#DC1510",
"noelleMint": "#ECFFBB",
"noelleCyan": "#77E0FF",
"noelleAqua": "#BBFFFC",
"gold": "#FBCE3C",
"orange": "#F4A731",
"hotPink": "#EB0095",
"queenPink": "#F983D8",
"cyberGreen": "#00FF00",
"white": "#FFFFFF",
"black": "#000000",
"textMuted": "#8888AA"
},
"theme": {
"primary": {
"dark": "hotPink",
"light": "susieMagenta"
},
"secondary": {
"dark": "krisCyan",
"light": "krisBlue"
},
"accent": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"error": {
"dark": "noelleRed",
"light": "noelleRed"
},
"warning": {
"dark": "gold",
"light": "orange"
},
"success": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"info": {
"dark": "noelleCyan",
"light": "krisBlue"
},
"text": {
"dark": "white",
"light": "black"
},
"textMuted": {
"dark": "textMuted",
"light": "#555577"
},
"background": {
"dark": "darkWorldBg",
"light": "white"
},
"backgroundPanel": {
"dark": "darkWorldDeep",
"light": "#F0F0F8"
},
"backgroundElement": {
"dark": "darkWorldPanel",
"light": "#E5E5F0"
},
"border": {
"dark": "krisBlue",
"light": "susiePurple"
},
"borderActive": {
"dark": "hotPink",
"light": "susieMagenta"
},
"borderSubtle": {
"dark": "#3A3A6A",
"light": "#AAAACC"
},
"diffAdded": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"diffRemoved": {
"dark": "hotPink",
"light": "noelleRed"
},
"diffContext": {
"dark": "textMuted",
"light": "#666688"
},
"diffHunkHeader": {
"dark": "krisBlue",
"light": "susiePurple"
},
"diffHighlightAdded": {
"dark": "ralseiGreen",
"light": "ralseiTeal"
},
"diffHighlightRemoved": {
"dark": "noelleRed",
"light": "hotPink"
},
"diffAddedBg": {
"dark": "#0A2A2A",
"light": "#D4FFEE"
},
"diffRemovedBg": {
"dark": "#2A0A2A",
"light": "#FFD4E8"
},
"diffContextBg": {
"dark": "darkWorldDeep",
"light": "#F5F5FA"
},
"diffLineNumber": {
"dark": "textMuted",
"light": "#666688"
},
"diffAddedLineNumberBg": {
"dark": "#082020",
"light": "#E0FFF0"
},
"diffRemovedLineNumberBg": {
"dark": "#200820",
"light": "#FFE0F0"
},
"markdownText": {
"dark": "white",
"light": "black"
},
"markdownHeading": {
"dark": "gold",
"light": "orange"
},
"markdownLink": {
"dark": "krisCyan",
"light": "krisBlue"
},
"markdownLinkText": {
"dark": "noelleCyan",
"light": "susiePurple"
},
"markdownCode": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"markdownBlockQuote": {
"dark": "textMuted",
"light": "#666688"
},
"markdownEmph": {
"dark": "susiePink",
"light": "susieMagenta"
},
"markdownStrong": {
"dark": "hotPink",
"light": "susiePurple"
},
"markdownHorizontalRule": {
"dark": "krisBlue",
"light": "susiePurple"
},
"markdownListItem": {
"dark": "gold",
"light": "orange"
},
"markdownListEnumeration": {
"dark": "krisCyan",
"light": "krisBlue"
},
"markdownImage": {
"dark": "susieMagenta",
"light": "susiePurple"
},
"markdownImageText": {
"dark": "susiePink",
"light": "susieMagenta"
},
"markdownCodeBlock": {
"dark": "white",
"light": "black"
},
"syntaxComment": {
"dark": "textMuted",
"light": "#666688"
},
"syntaxKeyword": {
"dark": "hotPink",
"light": "susieMagenta"
},
"syntaxFunction": {
"dark": "krisCyan",
"light": "krisBlue"
},
"syntaxVariable": {
"dark": "gold",
"light": "orange"
},
"syntaxString": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"syntaxNumber": {
"dark": "noelleRose",
"light": "noelleRed"
},
"syntaxType": {
"dark": "noelleCyan",
"light": "krisBlue"
},
"syntaxOperator": {
"dark": "white",
"light": "black"
},
"syntaxPunctuation": {
"dark": "krisBlue",
"light": "#555577"
}
}
}

View File

@@ -0,0 +1,232 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"black": "#000000",
"white": "#FFFFFF",
"soulRed": "#FF0000",
"soulOrange": "#FF6600",
"soulYellow": "#FFFF00",
"soulGreen": "#00FF00",
"soulAqua": "#00FFFF",
"soulBlue": "#0000FF",
"soulPurple": "#FF00FF",
"ruinsPurple": "#A349A4",
"ruinsDark": "#380A43",
"snowdinBlue": "#6BA3E5",
"hotlandOrange": "#FF7F27",
"coreGray": "#3A3949",
"battleBg": "#0D0D1A",
"battlePanel": "#1A1A2E",
"uiYellow": "#FFC90E",
"textGray": "#909090",
"damageRed": "#FF3333",
"healGreen": "#00FF00",
"saveYellow": "#FFFF00",
"determinationRed": "#FF0000",
"mttPink": "#FF6EB4",
"waterfall": "#283197",
"waterfallGlow": "#00BFFF"
},
"theme": {
"primary": {
"dark": "soulRed",
"light": "determinationRed"
},
"secondary": {
"dark": "uiYellow",
"light": "uiYellow"
},
"accent": {
"dark": "soulAqua",
"light": "soulBlue"
},
"error": {
"dark": "damageRed",
"light": "soulRed"
},
"warning": {
"dark": "uiYellow",
"light": "hotlandOrange"
},
"success": {
"dark": "healGreen",
"light": "soulGreen"
},
"info": {
"dark": "soulAqua",
"light": "waterfallGlow"
},
"text": {
"dark": "white",
"light": "black"
},
"textMuted": {
"dark": "textGray",
"light": "coreGray"
},
"background": {
"dark": "black",
"light": "white"
},
"backgroundPanel": {
"dark": "battleBg",
"light": "#F0F0F0"
},
"backgroundElement": {
"dark": "battlePanel",
"light": "#E5E5E5"
},
"border": {
"dark": "white",
"light": "black"
},
"borderActive": {
"dark": "soulRed",
"light": "determinationRed"
},
"borderSubtle": {
"dark": "#555555",
"light": "#AAAAAA"
},
"diffAdded": {
"dark": "healGreen",
"light": "soulGreen"
},
"diffRemoved": {
"dark": "damageRed",
"light": "soulRed"
},
"diffContext": {
"dark": "textGray",
"light": "coreGray"
},
"diffHunkHeader": {
"dark": "soulAqua",
"light": "soulBlue"
},
"diffHighlightAdded": {
"dark": "soulGreen",
"light": "healGreen"
},
"diffHighlightRemoved": {
"dark": "soulRed",
"light": "determinationRed"
},
"diffAddedBg": {
"dark": "#002200",
"light": "#CCFFCC"
},
"diffRemovedBg": {
"dark": "#220000",
"light": "#FFCCCC"
},
"diffContextBg": {
"dark": "battleBg",
"light": "#F5F5F5"
},
"diffLineNumber": {
"dark": "textGray",
"light": "coreGray"
},
"diffAddedLineNumberBg": {
"dark": "#001A00",
"light": "#E0FFE0"
},
"diffRemovedLineNumberBg": {
"dark": "#1A0000",
"light": "#FFE0E0"
},
"markdownText": {
"dark": "white",
"light": "black"
},
"markdownHeading": {
"dark": "uiYellow",
"light": "hotlandOrange"
},
"markdownLink": {
"dark": "soulAqua",
"light": "soulBlue"
},
"markdownLinkText": {
"dark": "waterfallGlow",
"light": "waterfall"
},
"markdownCode": {
"dark": "healGreen",
"light": "soulGreen"
},
"markdownBlockQuote": {
"dark": "textGray",
"light": "coreGray"
},
"markdownEmph": {
"dark": "mttPink",
"light": "soulPurple"
},
"markdownStrong": {
"dark": "soulRed",
"light": "determinationRed"
},
"markdownHorizontalRule": {
"dark": "white",
"light": "black"
},
"markdownListItem": {
"dark": "uiYellow",
"light": "uiYellow"
},
"markdownListEnumeration": {
"dark": "uiYellow",
"light": "uiYellow"
},
"markdownImage": {
"dark": "ruinsPurple",
"light": "soulPurple"
},
"markdownImageText": {
"dark": "mttPink",
"light": "ruinsPurple"
},
"markdownCodeBlock": {
"dark": "white",
"light": "black"
},
"syntaxComment": {
"dark": "textGray",
"light": "coreGray"
},
"syntaxKeyword": {
"dark": "soulRed",
"light": "determinationRed"
},
"syntaxFunction": {
"dark": "soulAqua",
"light": "soulBlue"
},
"syntaxVariable": {
"dark": "uiYellow",
"light": "hotlandOrange"
},
"syntaxString": {
"dark": "healGreen",
"light": "soulGreen"
},
"syntaxNumber": {
"dark": "mttPink",
"light": "soulPurple"
},
"syntaxType": {
"dark": "waterfallGlow",
"light": "waterfall"
},
"syntaxOperator": {
"dark": "white",
"light": "black"
},
"syntaxPunctuation": {
"dark": "textGray",
"light": "coreGray"
}
}
}

View File

@@ -32,99 +32,6 @@ export default defineConfig({
solidJs(),
starlight({
title: "OpenCode",
defaultLocale: "root",
locales: {
root: {
label: "English",
lang: "en",
dir: "ltr",
},
ar: {
label: "العربية",
lang: "ar",
dir: "rtl",
},
bs: {
label: "Bosanski",
lang: "bs-BA",
dir: "ltr",
},
da: {
label: "Dansk",
lang: "da-DK",
dir: "ltr",
},
de: {
label: "Deutsch",
lang: "de-DE",
dir: "ltr",
},
es: {
label: "Espa\u00f1ol",
lang: "es-ES",
dir: "ltr",
},
fr: {
label: "Fran\u00e7ais",
lang: "fr-FR",
dir: "ltr",
},
it: {
label: "Italiano",
lang: "it-IT",
dir: "ltr",
},
ja: {
label: "日本語",
lang: "ja-JP",
dir: "ltr",
},
ko: {
label: "한국어",
lang: "ko-KR",
dir: "ltr",
},
nb: {
label: "Norsk Bokm\u00e5l",
lang: "nb-NO",
dir: "ltr",
},
pl: {
label: "Polski",
lang: "pl-PL",
dir: "ltr",
},
"pt-br": {
label: "Portugu\u00eas (Brasil)",
lang: "pt-BR",
dir: "ltr",
},
ru: {
label: "Русский",
lang: "ru-RU",
dir: "ltr",
},
th: {
label: "ไทย",
lang: "th-TH",
dir: "ltr",
},
tr: {
label: "T\u00fcrk\u00e7e",
lang: "tr-TR",
dir: "ltr",
},
"zh-cn": {
label: "简体中文",
lang: "zh-CN",
dir: "ltr",
},
"zh-tw": {
label: "繁體中文",
lang: "zh-TW",
dir: "ltr",
},
},
favicon: "/favicon-v3.svg",
head: [
{
@@ -182,51 +89,11 @@ export default defineConfig({
"1-0",
{
label: "Usage",
translations: {
en: "Usage",
ar: "الاستخدام",
"bs-BA": "Korištenje",
"da-DK": "Brug",
"de-DE": "Nutzung",
"es-ES": "Uso",
"fr-FR": "Utilisation",
"it-IT": "Utilizzo",
"ja-JP": "使い方",
"ko-KR": "사용",
"nb-NO": "Bruk",
"pl-PL": "Użycie",
"pt-BR": "Uso",
"ru-RU": "Использование",
"th-TH": "การใช้งาน",
"tr-TR": "Kullanım",
"zh-CN": "使用",
"zh-TW": "使用",
},
items: ["tui", "cli", "web", "ide", "zen", "share", "github", "gitlab"],
},
{
label: "Configure",
translations: {
en: "Configure",
ar: "الإعداد",
"bs-BA": "Podešavanje",
"da-DK": "Konfiguration",
"de-DE": "Konfiguration",
"es-ES": "Configuración",
"fr-FR": "Configuration",
"it-IT": "Configurazione",
"ja-JP": "設定",
"ko-KR": "구성",
"nb-NO": "Konfigurasjon",
"pl-PL": "Konfiguracja",
"pt-BR": "Configuração",
"ru-RU": "Настройка",
"th-TH": "การกำหนดค่า",
"tr-TR": "Yapılandırma",
"zh-CN": "配置",
"zh-TW": "設定",
},
items: [
"tools",
"rules",
@@ -247,26 +114,6 @@ export default defineConfig({
{
label: "Develop",
translations: {
en: "Develop",
ar: "التطوير",
"bs-BA": "Razvoj",
"da-DK": "Udvikling",
"de-DE": "Entwicklung",
"es-ES": "Desarrollo",
"fr-FR": "Développement",
"it-IT": "Sviluppo",
"ja-JP": "開発",
"ko-KR": "개발",
"nb-NO": "Utvikling",
"pl-PL": "Rozwój",
"pt-BR": "Desenvolvimento",
"ru-RU": "Разработка",
"th-TH": "การพัฒนา",
"tr-TR": "Geliştirme",
"zh-CN": "开发",
"zh-TW": "開發",
},
items: ["sdk", "server", "plugins", "ecosystem"],
},
],
@@ -274,7 +121,6 @@ export default defineConfig({
Hero: "./src/components/Hero.astro",
Head: "./src/components/Head.astro",
Header: "./src/components/Header.astro",
Footer: "./src/components/Footer.astro",
SiteTitle: "./src/components/SiteTitle.astro",
},
plugins: [

View File

@@ -8,7 +8,7 @@ export default {
github: "https://github.com/anomalyco/opencode",
discord: "https://opencode.ai/discord",
headerLinks: [
{ name: "app.header.home", url: "/" },
{ name: "app.header.docs", url: "/docs/" },
{ name: "Home", url: "/" },
{ name: "Docs", url: "/docs/" },
],
}

View File

@@ -17,7 +17,7 @@
"@astrojs/solid-js": "5.1.0",
"@astrojs/starlight": "0.34.3",
"@fontsource/ibm-plex-mono": "5.2.5",
"@shikijs/transformers": "3.20.0",
"@shikijs/transformers": "3.4.2",
"@types/luxon": "catalog:",
"ai": "catalog:",
"astro": "5.7.13",
@@ -31,13 +31,11 @@
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"toolbeam-docs-theme": "0.4.8",
"vscode-languageserver-types": "3.17.5"
"toolbeam-docs-theme": "0.4.8"
},
"devDependencies": {
"opencode": "workspace:*",
"@types/node": "catalog:",
"@astrojs/check": "0.9.6",
"typescript": "catalog:"
}
}

View File

@@ -1,125 +0,0 @@
---
import config from "virtual:starlight/user-config"
import LanguageSelect from "@astrojs/starlight/components/LanguageSelect.astro"
import { Icon } from "@astrojs/starlight/components"
const { lang, editUrl, lastUpdated, entry } = Astro.locals.starlightRoute
const template = entry.data.template
const issueLink = Astro.locals.t("app.footer.issueLink", "Found a bug? Open an issue")
const discordLink = Astro.locals.t("app.footer.discordLink", "Join our Discord community")
const github = config.social?.find((item) => item.icon === "github")
const discord = config.social?.find((item) => item.icon === "discord")
---
{
template === "doc" && (
<footer class="doc">
<div class="meta sl-flex">
<div>
{
editUrl && (
<a href={editUrl} target="_blank" rel="noopener noreferrer" class="sl-flex">
<Icon name="pencil" size="1em" />
{Astro.locals.t("page.editLink")}
</a>
)
}
{
github && (
<a href={`${github.href}/issues/new`} target="_blank" rel="noopener noreferrer" class="sl-flex">
<Icon name={github.icon} size="1em" />
{issueLink}
</a>
)
}
{
discord && (
<a href={discord.href} target="_blank" rel="noopener noreferrer" class="sl-flex">
<Icon name={discord.icon} size="1em" />
{discordLink}
</a>
)
}
<LanguageSelect />
</div>
<div>
<p>&copy; <a target="_blank" rel="noopener noreferrer" href="https://anoma.ly">Anomaly</a></p>
<p title={Astro.locals.t("page.lastUpdated")}>
{Astro.locals.t("page.lastUpdated")} {" "}
{
lastUpdated ? (
<time datetime={lastUpdated.toISOString()}>
{lastUpdated.toLocaleDateString(lang, { dateStyle: "medium", timeZone: "UTC" })}
</time>
) : (
"-"
)
}
</p>
</div>
</div>
</footer>
)
}
<style>
footer.doc {
margin-top: 3rem;
border-top: 1px solid var(--sl-color-border);
}
.meta {
gap: 0.75rem 3rem;
justify-content: space-between;
flex-wrap: wrap;
margin-block: 3rem 1.5rem;
font-size: var(--sl-text-sm);
}
@media (min-width: 30rem) {
.meta {
flex-direction: row;
}
}
.doc a,
.doc p {
padding-block: 0.125rem;
}
.doc a {
gap: 0.4375rem;
align-items: center;
text-decoration: none;
color: var(--sl-color-text);
font-size: var(--sl-text-sm);
}
.doc a svg {
opacity: 0.85;
}
.doc p {
color: var(--sl-color-text-dimmed);
}
.doc :global(starlight-lang-select) {
display: inline-flex;
margin-top: 0.5rem;
}
.doc :global(starlight-lang-select select) {
min-width: 7em;
}
@media (min-width: 30rem) {
.doc p {
text-align: right;
}
}
.doc p a {
color: var(--sl-color-text-dimmed);
}
</style>

View File

@@ -1,9 +1,10 @@
---
import { Base64 } from "js-base64";
import type { Props } from '@astrojs/starlight/props'
import Default from '@astrojs/starlight/components/Head.astro'
import config from '../../config.mjs'
const base = import.meta.env.BASE_URL.replace(/^\//, "").replace(/\/$/, "")
const base = import.meta.env.BASE_URL.slice(1)
const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, "");
const {
@@ -11,12 +12,7 @@ const {
data: { title , description },
},
} = Astro.locals.starlightRoute;
const isDocs = base === "" ? true : slug === base || slug.startsWith(`${base}/`)
const t = Astro.locals.t as (key: string) => string
const titleSuffix = t("app.head.titleSuffix")
const shareSlug = base === "" ? "s" : `${base}/s`
const isShare = slug === shareSlug || slug.startsWith(`${shareSlug}/`)
const isHome = slug === "" || slug === base
const isDocs = slug.startsWith("docs")
let encodedTitle = '';
let ogImage = `${config.url}/social-share.png`;
@@ -42,13 +38,13 @@ if (isDocs) {
}
---
{ isHome && (
<title>{title} | {titleSuffix}</title>
{ slug === "" && (
<title>{title} | AI coding agent built for the terminal</title>
)}
<Default {...Astro.props}><slot /></Default>
{ !isShare && (
{ (!slug.startsWith(`${base}/s`)) && (
<meta property="og:image" content={ogImage} />
<meta property="twitter:image" content={ogImage} />
)}

View File

@@ -1,136 +1,128 @@
---
import config from "../../config.mjs"
import astroConfig from "virtual:starlight/user-config"
import { getRelativeLocaleUrl } from "astro:i18n"
import { Icon } from "@astrojs/starlight/components"
import Default from "toolbeam-docs-theme/overrides/Header.astro"
import SiteTitle from "@astrojs/starlight/components/SiteTitle.astro"
import config from '../../config.mjs';
import astroConfig from 'virtual:starlight/user-config';
import { Icon } from '@astrojs/starlight/components';
import { HeaderLinks } from 'toolbeam-docs-theme/components';
import Default from 'toolbeam-docs-theme/overrides/Header.astro';
import SocialIcons from 'virtual:starlight/components/SocialIcons';
import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro';
const path = Astro.url.pathname
const locale = Astro.currentLocale || "root"
const route = Astro.locals.starlightRoute
const t = Astro.locals.t as (key: string) => string
const links = astroConfig.social || []
const headerLinks = config.headerLinks
const sharePath = /\/s(\/|$)/.test(path)
const path = Astro.url.pathname;
function href(url: string) {
if (url === "/" || url === "/docs" || url === "/docs/") {
return getRelativeLocaleUrl(locale, "")
}
return url
const links = astroConfig.social || [];
const headerLinks = config.headerLinks;
---
{ path.startsWith("/s")
? <div class="header sl-flex">
<div class="title-wrapper sl-flex">
<SiteTitle {...Astro.props} />
</div>
<div class="middle-group sl-flex">
{
headerLinks?.map(({ name, url }) => (
<a class="links" href={url}>{name}</a>
))
}
</div>
<div class="sl-hidden md:sl-flex right-group">
{
links.length > 0 && (
<div class="sl-flex social-icons">
{links.map(({ href, icon }) => (
<a {href} rel="me" target="_blank">
<Icon name={icon} size="1rem" />
</a>
))}
</div>
)
}
</div>
</div>
: <Default {...Astro.props}><slot /></Default>
}
---
{sharePath ? (
<div class="header sl-flex">
<div class="title-wrapper sl-flex">
<SiteTitle {...route} />
</div>
<div class="middle-group sl-flex">
{headerLinks?.map(({ name, url }) => (
<a class="links" href={href(url)}>{t(name)}</a>
))}
</div>
<div class="sl-hidden md:sl-flex right-group">
{links.length > 0 && (
<div class="sl-flex social-icons">
{links.map(({ href, icon }) => (
<a {href} rel="me" target="_blank">
<Icon name={icon} size="1rem" />
</a>
))}
</div>
)}
</div>
</div>
) : (
<Default {...route} />
)}
<style>
.header {
gap: var(--sl-nav-gap);
justify-content: space-between;
align-items: center;
height: 100%;
}
.header {
gap: var(--sl-nav-gap);
justify-content: space-between;
align-items: center;
height: 100%;
}
.title-wrapper {
/* Prevent long titles overflowing and covering the search and menu buttons on narrow viewports. */
overflow: clip;
/* Avoid clipping focus ring around link inside title wrapper. */
.title-wrapper {
/* Prevent long titles overflowing and covering the search and menu buttons on narrow viewports. */
overflow: clip;
/* Avoid clipping focus ring around link inside title wrapper. */
padding: calc(0.25rem + 2px) 0.25rem calc(0.25rem - 2px);
margin: -0.25rem;
}
margin: -0.25rem;
}
.middle-group {
justify-content: flex-end;
gap: var(--sl-nav-gap);
}
.middle-group {
justify-content: flex-end;
gap: var(--sl-nav-gap);
}
@media (max-width: 50rem) {
:global(:root[data-has-sidebar]) {
.middle-group {
display: none;
}
}
}
@media (min-width: 50rem) {
.middle-group {
display: flex;
}
}
@media (max-width: 50rem) {
:global(:root[data-has-sidebar]) {
.middle-group {
display: none;
}
}
}
@media (min-width: 50rem) {
.middle-group {
display: flex;
}
}
.right-group,
.social-icons {
gap: 1rem;
align-items: center;
.right-group,
.social-icons {
gap: 1rem;
align-items: center;
a {
line-height: 1;
line-height: 1;
svg {
color: var(--sl-color-text-dimmed);
svg {
color: var(--sl-color-text-dimmed);
}
}
}
a.links {
text-transform: uppercase;
font-size: var(--sl-text-sm);
color: var(--sl-color-text-secondary);
line-height: normal;
}
a.links {
text-transform: uppercase;
font-size: var(--sl-text-sm);
color: var(--sl-color-text-secondary);
line-height: normal;
}
}
@media (min-width: 50rem) {
:global(:root[data-has-sidebar]) {
--__sidebar-pad: calc(2 * var(--sl-nav-pad-x));
}
:global(:root:not([data-has-toc])) {
--__toc-width: 0rem;
}
.header {
--__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x));
--__main-column-fr: calc(
(
100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) -
(2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) -
var(--sl-content-width)
) / 2
);
display: grid;
grid-template-columns:
@media (min-width: 50rem) {
:global(:root[data-has-sidebar]) {
--__sidebar-pad: calc(2 * var(--sl-nav-pad-x));
}
:global(:root:not([data-has-toc])) {
--__toc-width: 0rem;
}
.header {
--__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x));
--__main-column-fr: calc(
(
100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) -
(2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) -
var(--sl-content-width)
) / 2
);
display: grid;
grid-template-columns:
/* 1 (site title): runs up until the main content columns left edge or the width of the title, whichever is the largest */
minmax(calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))), auto)
/* 2 (search box): all free space that is available. */
1fr
/* 3 (right items): use the space that these need. */
auto;
align-content: center;
}
}
minmax(
calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))),
auto
)
/* 2 (search box): all free space that is available. */
1fr
/* 3 (right items): use the space that these need. */
auto;
align-content: center;
}
}
</style>

View File

@@ -1,7 +1,7 @@
---
import { Image } from 'astro:assets';
import { getRelativeLocaleUrl } from 'astro:i18n';
import config from "virtual:starlight/user-config";
import type { Props } from '@astrojs/starlight/props';
import CopyIcon from "../assets/lander/copy.svg";
import CheckIcon from "../assets/lander/check.svg";
@@ -19,14 +19,8 @@ const imageAttrs = {
alt: image?.alt || '',
};
const github = (config.social || []).filter(s => s.icon === 'github')[0];
const discord = (config.social || []).filter(s => s.icon === 'discord')[0];
const locale = Astro.currentLocale || 'root';
const t = Astro.locals.t as (key: string) => string;
const docsHref = getRelativeLocaleUrl(locale, "")
const docsCliHref = getRelativeLocaleUrl(locale, "cli")
const docsIdeHref = getRelativeLocaleUrl(locale, "ide")
const docsGithubHref = getRelativeLocaleUrl(locale, "github")
const github = config.social.filter(s => s.icon === 'github')[0];
const discord = config.social.filter(s => s.icon === 'discord')[0];
const command = "curl -fsSL"
const protocol = "https://"
@@ -50,21 +44,19 @@ if (image) {
<div class="hero">
<section class="top">
<div class="logo">
{darkImage && (
<Image
src={darkImage}
{...imageAttrs}
class:list={{ 'light:sl-hidden': Boolean(lightImage) }}
/>
)}
{lightImage && <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />}
<Image
src={darkImage}
{...imageAttrs}
class:list={{ 'light:sl-hidden': Boolean(lightImage) }}
/>
<Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />
</div>
<h1>{t('app.lander.hero.title')}</h1>
<h1>The AI coding agent built for the terminal.</h1>
</section>
<section class="cta">
<div class="col1">
<a href={docsHref}>{t('app.lander.cta.getStarted')}</a>
<a href="/docs">Get Started</a>
</div>
<div class="col2">
<button class="command" data-command={`${command} ${protocol}${url} ${bash}`}>
@@ -81,13 +73,13 @@ if (image) {
<section class="content">
<ul>
<li><b>{t('app.lander.features.native_tui.title')}</b>: {t('app.lander.features.native_tui.description')}</li>
<li><b>{t('app.lander.features.lsp_enabled.title')}</b>: {t('app.lander.features.lsp_enabled.description')}</li>
<li><b>{t('app.lander.features.multi_session.title')}</b>: {t('app.lander.features.multi_session.description')}</li>
<li><b>{t('app.lander.features.shareable_links.title')}</b>: {t('app.lander.features.shareable_links.description')}</li>
<li><b>GitHub Copilot</b>: {t('app.lander.features.github_copilot.description')}</li>
<li><b>ChatGPT Plus/Pro</b>: {t('app.lander.features.chatgpt_plus_pro.description')}</li>
<li><b>{t('app.lander.features.use_any_model.title')}</b>: {t('app.lander.features.use_any_model.prefix')} <a href="https://models.dev">Models.dev</a>, {t('app.lander.features.use_any_model.suffix')}</li>
<li><b>Native TUI</b>: A responsive, native, themeable terminal UI.</li>
<li><b>LSP enabled</b>: Automatically loads the right LSPs for the LLM.</li>
<li><b>Multi-session</b>: Start multiple agents in parallel on the same project.</li>
<li><b>Shareable links</b>: Share a link to any sessions for reference or to debug.</li>
<li><b>GitHub Copilot</b>: Log in with GitHub to use your Copilot account.</li>
<li><b>ChatGPT Plus/Pro</b>: Log in with OpenAI to use your ChatGPT Plus or Pro account.</li>
<li><b>Use any model</b>: Supports 75+ LLM providers through <a href="https://models.dev">Models.dev</a>, including local models.</li>
</ul>
</section>
@@ -157,26 +149,26 @@ if (image) {
<section class="images">
<div class="left">
<figure>
<figcaption>{t('app.lander.images.tui.caption')}</figcaption>
<a href={docsCliHref}>
<Image src={TuiScreenshot} alt={t('app.lander.images.tui.alt')} />
<figcaption>opencode TUI with the tokyonight theme</figcaption>
<a href="/docs/cli">
<Image src={TuiScreenshot} alt="opencode TUI with the tokyonight theme" />
</a>
</figure>
</div>
<div class="right">
<div class="row1">
<figure>
<figcaption>{t('app.lander.images.vscode.caption')}</figcaption>
<a href={docsIdeHref}>
<Image src={VscodeScreenshot} alt={t('app.lander.images.vscode.alt')} />
<figcaption>opencode in VS Code</figcaption>
<a href="/docs/ide">
<Image src={VscodeScreenshot} alt="opencode in VS Code" />
</a>
</figure>
</div>
<div class="row2">
<figure>
<figcaption>{t('app.lander.images.github.caption')}</figcaption>
<a href={docsGithubHref}>
<Image src={GithubScreenshot} alt={t('app.lander.images.github.alt')} />
<figcaption>opencode in GitHub</figcaption>
<a href="/docs/github">
<Image src={GithubScreenshot} alt="opencode in GitHub" />
</a>
</figure>
</div>

View File

@@ -1,9 +1,8 @@
import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList } from "solid-js"
import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList, createEffect } from "solid-js"
import { DateTime } from "luxon"
import { createStore, reconcile } from "solid-js/store"
import { createStore, reconcile, unwrap } from "solid-js/store"
import { IconArrowDown } from "./icons"
import { IconOpencode } from "./icons/custom"
import { ShareI18nProvider, formatCurrency, formatNumber, normalizeLocale } from "./share/common"
import styles from "./share.module.css"
import type { MessageV2 } from "opencode/session/message-v2"
import type { Message } from "opencode/session/message"
@@ -21,29 +20,24 @@ function scrollToAnchor(id: string) {
el.scrollIntoView({ behavior: "smooth" })
}
function getStatusText(status: [Status, string?], messages: Record<string, string>): string {
function getStatusText(status: [Status, string?]): string {
switch (status[0]) {
case "connected":
return messages.status_connected_waiting
return "Connected, waiting for messages..."
case "connecting":
return messages.status_connecting
return "Connecting..."
case "disconnected":
return messages.status_disconnected
return "Disconnected"
case "reconnecting":
return messages.status_reconnecting
return "Reconnecting..."
case "error":
return status[1] || messages.status_error
return status[1] || "Error"
default:
return messages.status_unknown
return "Unknown"
}
}
export default function Share(props: {
id: string
api: string
info: Session.Info
messages: { locale: string } & Record<string, string>
}) {
export default function Share(props: { id: string; api: string; info: Session.Info }) {
let lastScrollY = 0
let hasScrolledToAnchor = false
let scrollTimeout: number | undefined
@@ -63,9 +57,6 @@ export default function Share(props: {
}>({
info: {
id: props.id,
slug: props.info.slug,
projectID: props.info.projectID,
directory: props.info.directory,
title: props.info.title,
version: props.info.version,
time: {
@@ -76,19 +67,22 @@ export default function Share(props: {
messages: {},
})
const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)))
const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected"])
const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"])
createEffect(() => {
console.log(unwrap(store))
})
onMount(() => {
const apiUrl = props.api
if (!props.id) {
setConnectionStatus(["error", props.messages.error_id_not_found])
setConnectionStatus(["error", "id not found"])
return
}
if (!apiUrl) {
console.error("API URL not found in environment variables")
setConnectionStatus(["error", props.messages.error_api_url_not_found])
setConnectionStatus(["error", "API URL not found"])
return
}
@@ -107,16 +101,20 @@ export default function Share(props: {
// Always use secure WebSocket protocol (wss)
const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://")
const wsUrl = `${wsBaseUrl}/share_poll?id=${props.id}`
console.log("Connecting to WebSocket URL:", wsUrl)
// Create WebSocket connection
socket = new WebSocket(wsUrl)
// Handle connection opening
socket.onopen = () => {
setConnectionStatus(["connected"])
console.log("WebSocket connection established")
}
// Handle incoming messages
socket.onmessage = (event) => {
console.log("WebSocket message received")
try {
const d = JSON.parse(event.data)
const [root, type, ...splits] = d.key.split("/")
@@ -149,11 +147,12 @@ export default function Share(props: {
// Handle errors
socket.onerror = (error) => {
console.error("WebSocket error:", error)
setConnectionStatus(["error", props.messages.error_connection_failed])
setConnectionStatus(["error", "Connection failed"])
}
// Handle connection close and reconnection
socket.onclose = () => {
socket.onclose = (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`)
setConnectionStatus(["reconnecting"])
// Try to reconnect after 2 seconds
@@ -167,6 +166,7 @@ export default function Share(props: {
// Clean up on component unmount
onCleanup(() => {
console.log("Cleaning up WebSocket connection")
if (socket) {
socket.close()
}
@@ -297,212 +297,201 @@ export default function Share(props: {
return (
<Show when={store.info}>
<ShareI18nProvider messages={props.messages}>
<main classList={{ [styles.root]: true, "not-content": true }}>
<div data-component="header">
<h1 data-component="header-title">{store.info?.title}</h1>
<div data-component="header-details">
<ul data-component="header-stats">
<li title={props.messages.opencode_version} data-slot="item">
<div data-slot="icon" title={props.messages.opencode_name}>
<IconOpencode width={16} height={16} />
</div>
<Show when={store.info?.version} fallback="v0.0.1">
<span>v{store.info?.version}</span>
</Show>
<main classList={{ [styles.root]: true, "not-content": true }}>
<div data-component="header">
<h1 data-component="header-title">{store.info?.title}</h1>
<div data-component="header-details">
<ul data-component="header-stats">
<li title="opencode version" data-slot="item">
<div data-slot="icon" title="opencode">
<IconOpencode width={16} height={16} />
</div>
<Show when={store.info?.version} fallback="v0.0.1">
<span>v{store.info?.version}</span>
</Show>
</li>
{Object.values(data().models).length > 0 ? (
<For each={Object.values(data().models)}>
{([provider, model]) => (
<li data-slot="item">
<div data-slot="icon" title={provider}>
<ProviderIcon model={model} />
</div>
<span data-slot="model">{model}</span>
</li>
)}
</For>
) : (
<li>
<span data-element-label>Models</span>
<span data-placeholder>&mdash;</span>
</li>
{Object.values(data().models).length > 0 ? (
<For each={Object.values(data().models)}>
{([provider, model]) => (
<li data-slot="item">
<div data-slot="icon" title={provider}>
<ProviderIcon model={model} />
)}
</ul>
<div
data-component="header-time"
title={DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
>
{DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_MED)}
</div>
</div>
</div>
<div>
<Show when={data().messages.length > 0} fallback={<p>Waiting for messages...</p>}>
<div class={styles.parts}>
<SuspenseList revealOrder="forwards">
<For each={data().messages}>
{(msg, msgIndex) => {
const filteredParts = createMemo(() =>
msg.parts.filter((x, index) => {
if (x.type === "step-start" && index > 0) return false
if (x.type === "snapshot") return false
if (x.type === "patch") return false
if (x.type === "step-finish") return false
if (x.type === "text" && x.synthetic === true) return false
if (x.type === "tool" && x.tool === "todoread") return false
if (x.type === "text" && !x.text) return false
if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
return false
return true
}),
)
return (
<Suspense>
<For each={filteredParts()}>
{(part, partIndex) => {
const last = createMemo(
() =>
data().messages.length === msgIndex() + 1 && filteredParts().length === partIndex() + 1,
)
onMount(() => {
const hash = window.location.hash.slice(1)
// Wait till all parts are loaded
if (
hash !== "" &&
!hasScrolledToAnchor &&
filteredParts().length === partIndex() + 1 &&
data().messages.length === msgIndex() + 1
) {
hasScrolledToAnchor = true
scrollToAnchor(hash)
}
})
return <Part last={last()} part={part} index={partIndex()} message={msg} />
}}
</For>
</Suspense>
)
}}
</For>
</SuspenseList>
<div data-section="part" data-part-type="summary">
<div data-section="decoration">
<span data-status={connectionStatus()[0]}></span>
</div>
<div data-section="content">
<p data-section="copy">{getStatusText(connectionStatus())}</p>
<ul data-section="stats">
<li>
<span data-element-label>Cost</span>
{data().cost !== undefined ? (
<span>${data().cost.toFixed(2)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Input Tokens</span>
{data().tokens.input ? <span>{data().tokens.input}</span> : <span data-placeholder>&mdash;</span>}
</li>
<li>
<span data-element-label>Output Tokens</span>
{data().tokens.output ? (
<span>{data().tokens.output}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Reasoning Tokens</span>
{data().tokens.reasoning ? (
<span>{data().tokens.reasoning}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
</ul>
</div>
</div>
</div>
</Show>
</div>
<Show when={debug}>
<div style={{ margin: "2rem 0" }}>
<div
style={{
border: "1px solid #ccc",
padding: "1rem",
"overflow-y": "auto",
}}
>
<Show when={data().messages.length > 0} fallback={<p>Waiting for messages...</p>}>
<ul style={{ "list-style-type": "none", padding: 0 }}>
<For each={data().messages}>
{(msg) => (
<li
style={{
padding: "0.75rem",
margin: "0.75rem 0",
"box-shadow": "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<div>
<strong>Key:</strong> {msg.id}
</div>
<span data-slot="model">{model}</span>
<pre>{JSON.stringify(msg, null, 2)}</pre>
</li>
)}
</For>
) : (
<li>
<span data-element-label>{props.messages.models}</span>
<span data-placeholder>&mdash;</span>
</li>
)}
</ul>
<div
data-component="header-time"
title={DateTime.fromMillis(data().created || 0)
.setLocale(normalizeLocale(props.messages.locale))
.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
>
{DateTime.fromMillis(data().created || 0)
.setLocale(normalizeLocale(props.messages.locale))
.toLocaleString(DateTime.DATETIME_MED)}
</div>
</ul>
</Show>
</div>
</div>
</Show>
<div>
<Show when={data().messages.length > 0} fallback={<p>{props.messages.waiting_for_messages}</p>}>
<div class={styles.parts}>
<SuspenseList revealOrder="forwards">
<For each={data().messages}>
{(msg, msgIndex) => {
const filteredParts = createMemo(() =>
msg.parts.filter((x, index) => {
if (x.type === "step-start" && index > 0) return false
if (x.type === "snapshot") return false
if (x.type === "patch") return false
if (x.type === "step-finish") return false
if (x.type === "text" && x.synthetic === true) return false
if (x.type === "tool" && x.tool === "todoread") return false
if (x.type === "text" && !x.text) return false
if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
return false
return true
}),
)
return (
<Suspense>
<For each={filteredParts()}>
{(part, partIndex) => {
const last = createMemo(
() =>
data().messages.length === msgIndex() + 1 &&
filteredParts().length === partIndex() + 1,
)
onMount(() => {
const hash = window.location.hash.slice(1)
// Wait till all parts are loaded
if (
hash !== "" &&
!hasScrolledToAnchor &&
filteredParts().length === partIndex() + 1 &&
data().messages.length === msgIndex() + 1
) {
hasScrolledToAnchor = true
scrollToAnchor(hash)
}
})
return <Part last={last()} part={part} index={partIndex()} message={msg} />
}}
</For>
</Suspense>
)
}}
</For>
</SuspenseList>
<div data-section="part" data-part-type="summary">
<div data-section="decoration">
<span data-status={connectionStatus()[0]}></span>
</div>
<div data-section="content">
<p data-section="copy">{getStatusText(connectionStatus(), props.messages)}</p>
<ul data-section="stats">
<li>
<span data-element-label>{props.messages.cost}</span>
{data().cost !== undefined ? (
<span>{formatCurrency(data().cost, props.messages.locale)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>{props.messages.input_tokens}</span>
{data().tokens.input ? (
<span>{formatNumber(data().tokens.input, props.messages.locale)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>{props.messages.output_tokens}</span>
{data().tokens.output ? (
<span>{formatNumber(data().tokens.output, props.messages.locale)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>{props.messages.reasoning_tokens}</span>
{data().tokens.reasoning ? (
<span>{formatNumber(data().tokens.reasoning, props.messages.locale)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
</ul>
</div>
</div>
</div>
</Show>
</div>
<Show when={debug}>
<div style={{ margin: "2rem 0" }}>
<div
style={{
border: "1px solid #ccc",
padding: "1rem",
"overflow-y": "auto",
}}
>
<Show when={data().messages.length > 0} fallback={<p>{props.messages.waiting_for_messages}</p>}>
<ul style={{ "list-style-type": "none", padding: 0 }}>
<For each={data().messages}>
{(msg) => (
<li
style={{
padding: "0.75rem",
margin: "0.75rem 0",
"box-shadow": "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<div>
<strong>{props.messages.debug_key}:</strong> {msg.id}
</div>
<pre>{JSON.stringify(msg, null, 2)}</pre>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
</Show>
<Show when={showScrollButton()}>
<button
type="button"
class={styles["scroll-button"]}
onClick={() => document.body.scrollIntoView({ behavior: "smooth", block: "end" })}
onMouseEnter={() => {
setIsButtonHovered(true)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
}}
onMouseLeave={() => {
setIsButtonHovered(false)
if (showScrollButton()) {
scrollTimeout = window.setTimeout(() => {
if (!isButtonHovered()) {
setShowScrollButton(false)
}
}, 3000)
}
}}
title={props.messages.scroll_to_bottom}
aria-label={props.messages.scroll_to_bottom}
>
<IconArrowDown width={20} height={20} />
</button>
</Show>
</main>
</ShareI18nProvider>
<Show when={showScrollButton()}>
<button
type="button"
class={styles["scroll-button"]}
onClick={() => document.body.scrollIntoView({ behavior: "smooth", block: "end" })}
onMouseEnter={() => {
setIsButtonHovered(true)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
}}
onMouseLeave={() => {
setIsButtonHovered(false)
if (showScrollButton()) {
scrollTimeout = window.setTimeout(() => {
if (!isButtonHovered()) {
setShowScrollButton(false)
}
}, 3000)
}
}}
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
<IconArrowDown width={20} height={20} />
</button>
</Show>
</main>
</Show>
)
}
@@ -513,8 +502,6 @@ export function fromV1(v1: Message.Info): MessageWithParts {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "assistant",
parentID: "",
agent: "build",
time: {
created: v1.metadata.time.created,
completed: v1.metadata.time.completed,
@@ -534,6 +521,7 @@ export function fromV1(v1: Message.Info): MessageWithParts {
modelID: v1.metadata.assistant!.modelID,
providerID: v1.metadata.assistant!.providerID,
mode: "build",
system: v1.metadata.assistant!.system,
error: v1.metadata.error,
parts: v1.parts.flatMap((part, index): MessageV2.Part[] => {
const base = {
@@ -569,8 +557,6 @@ export function fromV1(v1: Message.Info): MessageWithParts {
if (part.toolInvocation.state === "partial-call") {
return {
status: "pending",
input: {},
raw: "",
}
}
@@ -610,11 +596,6 @@ export function fromV1(v1: Message.Info): MessageWithParts {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "user",
agent: "user",
model: {
providerID: "",
modelID: "",
},
time: {
created: v1.metadata.time.created,
},

View File

@@ -4,7 +4,7 @@ import config from 'virtual:starlight/user-config';
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
---
<a href={siteTitleHref} class="site-title sl-flex">
<a href="/" class="site-title sl-flex">
{
config.logo && logos.dark && (
<>

View File

@@ -1,55 +1,16 @@
import { createContext, createSignal, onCleanup, splitProps, useContext } from "solid-js"
import { createSignal, onCleanup, splitProps } from "solid-js"
import type { JSX } from "solid-js/jsx-runtime"
import { IconCheckCircle, IconHashtag } from "../icons"
export type ShareMessages = { locale: string } & Record<string, string>
const shareContext = createContext<ShareMessages>()
export function ShareI18nProvider(props: { messages: ShareMessages; children: JSX.Element }) {
return <shareContext.Provider value={props.messages}>{props.children}</shareContext.Provider>
}
export function useShareMessages() {
const value = useContext(shareContext)
if (value) {
return value
}
throw new Error("ShareI18nProvider is required")
}
export function normalizeLocale(locale: string) {
return locale === "root" ? "en" : locale
}
export function formatNumber(value: number, locale: string) {
return new Intl.NumberFormat(normalizeLocale(locale)).format(value)
}
export function formatCurrency(value: number, locale: string) {
return new Intl.NumberFormat(normalizeLocale(locale), {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
export function formatCount(value: number, locale: string, singular: string, plural: string) {
const unit = value === 1 ? singular : plural
return `${formatNumber(value, locale)} ${unit}`
}
interface AnchorProps extends JSX.HTMLAttributes<HTMLDivElement> {
id: string
}
export function AnchorIcon(props: AnchorProps) {
const [local, rest] = splitProps(props, ["id", "children"])
const [copied, setCopied] = createSignal(false)
const messages = useShareMessages()
return (
<div {...rest} data-element-anchor title={messages.link_to_message} data-status={copied() ? "copied" : ""}>
<div {...rest} data-element-anchor title="Link to this message" data-status={copied() ? "copied" : ""}>
<a
href={`#${local.id}`}
onClick={(e) => {
@@ -71,7 +32,7 @@ export function AnchorIcon(props: AnchorProps) {
<IconHashtag width={18} height={18} />
<IconCheckCircle width={18} height={18} />
</a>
<span data-element-tooltip>{messages.copied}</span>
<span data-element-tooltip>Copied!</span>
</div>
)
}
@@ -98,33 +59,19 @@ export function createOverflow() {
}
}
export function formatDuration(ms: number, locale: string): string {
const normalized = normalizeLocale(locale)
export function formatDuration(ms: number): string {
const ONE_SECOND = 1000
const ONE_MINUTE = 60 * ONE_SECOND
if (ms >= ONE_MINUTE) {
return new Intl.NumberFormat(normalized, {
style: "unit",
unit: "minute",
unitDisplay: "narrow",
maximumFractionDigits: 0,
}).format(Math.floor(ms / ONE_MINUTE))
const minutes = Math.floor(ms / ONE_MINUTE)
return minutes === 1 ? `1min` : `${minutes}mins`
}
if (ms >= ONE_SECOND) {
return new Intl.NumberFormat(normalized, {
style: "unit",
unit: "second",
unitDisplay: "narrow",
maximumFractionDigits: 0,
}).format(Math.floor(ms / ONE_SECOND))
const seconds = Math.floor(ms / ONE_SECOND)
return `${seconds}s`
}
return new Intl.NumberFormat(normalized, {
style: "unit",
unit: "millisecond",
unitDisplay: "narrow",
maximumFractionDigits: 0,
}).format(ms)
return `${ms}ms`
}

View File

@@ -1,6 +1,6 @@
import style from "./content-bash.module.css"
import { createResource, createSignal } from "solid-js"
import { createOverflow, useShareMessages } from "./common"
import { createOverflow } from "./common"
import { codeToHtml } from "shiki"
interface Props {
@@ -11,7 +11,6 @@ interface Props {
}
export function ContentBash(props: Props) {
const messages = useShareMessages()
const [commandHtml] = createResource(
() => props.command,
async (command) => {
@@ -60,7 +59,7 @@ export function ContentBash(props: Props) {
data-slot="expand-button"
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? messages.show_less : messages.show_more}
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>

View File

@@ -1,5 +1,6 @@
import { codeToHtml, bundledLanguages } from "shiki"
import { createResource, Suspense } from "solid-js"
import { transformerNotationDiff } from "@shikijs/transformers"
import style from "./content-code.module.css"
interface Props {
@@ -19,6 +20,7 @@ export function ContentCode(props: Props) {
light: "github-light",
dark: "github-dark",
},
transformers: [transformerNotationDiff()],
})) as string
},
)

View File

@@ -1,6 +1,6 @@
import style from "./content-error.module.css"
import { type JSX, createSignal } from "solid-js"
import { createOverflow, useShareMessages } from "./common"
import { createOverflow } from "./common"
interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
expand?: boolean
@@ -8,7 +8,6 @@ interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
export function ContentError(props: Props) {
const [expanded, setExpanded] = createSignal(false)
const overflow = createOverflow()
const messages = useShareMessages()
return (
<div class={style.root} data-expanded={expanded() || props.expand === true ? true : undefined}>
@@ -17,7 +16,7 @@ export function ContentError(props: Props) {
</div>
{((!props.expand && overflow.status) || expanded()) && (
<button type="button" data-element-button-text onClick={() => setExpanded((e) => !e)}>
{expanded() ? messages.show_less : messages.show_more}
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>

View File

@@ -1,9 +1,10 @@
import { marked } from "marked"
import { codeToHtml } from "shiki"
import markedShiki from "marked-shiki"
import { createOverflow, useShareMessages } from "./common"
import { createOverflow } from "./common"
import { CopyButton } from "./copy-button"
import { createResource, createSignal } from "solid-js"
import { transformerNotationDiff } from "@shikijs/transformers"
import style from "./content-markdown.module.css"
const markedWithShiki = marked.use(
@@ -23,6 +24,7 @@ const markedWithShiki = marked.use(
light: "github-light",
dark: "github-dark",
},
transformers: [transformerNotationDiff()],
})
},
}),
@@ -42,7 +44,6 @@ export function ContentMarkdown(props: Props) {
)
const [expanded, setExpanded] = createSignal(false)
const overflow = createOverflow()
const messages = useShareMessages()
return (
<div
@@ -59,7 +60,7 @@ export function ContentMarkdown(props: Props) {
data-slot="expand-button"
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? messages.show_less : messages.show_more}
{expanded() ? "Show less" : "Show more"}
</button>
)}
<CopyButton text={props.text} />

View File

@@ -1,6 +1,6 @@
import style from "./content-text.module.css"
import { createSignal } from "solid-js"
import { createOverflow, useShareMessages } from "./common"
import { createOverflow } from "./common"
import { CopyButton } from "./copy-button"
interface Props {
@@ -11,7 +11,6 @@ interface Props {
export function ContentText(props: Props) {
const [expanded, setExpanded] = createSignal(false)
const overflow = createOverflow()
const messages = useShareMessages()
return (
<div
@@ -29,7 +28,7 @@ export function ContentText(props: Props) {
data-slot="expand-button"
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? messages.show_less : messages.show_more}
{expanded() ? "Show less" : "Show more"}
</button>
)}
<CopyButton text={props.text} />

View File

@@ -1,6 +1,5 @@
import { createSignal } from "solid-js"
import { IconClipboard, IconCheckCircle } from "../icons"
import { useShareMessages } from "./common"
import styles from "./copy-button.module.css"
interface CopyButtonProps {
@@ -9,7 +8,6 @@ interface CopyButtonProps {
export function CopyButton(props: CopyButtonProps) {
const [copied, setCopied] = createSignal(false)
const messages = useShareMessages()
function handleCopyClick() {
if (props.text) {
@@ -22,13 +20,7 @@ export function CopyButton(props: CopyButtonProps) {
return (
<div data-component="copy-button" class={styles.root}>
<button
type="button"
onClick={handleCopyClick}
data-copied={copied() ? true : undefined}
aria-label={copied() ? messages.copied : messages.copy}
title={copied() ? messages.copied : messages.copy}
>
<button type="button" onClick={handleCopyClick} data-copied={copied() ? true : undefined}>
{copied() ? <IconCheckCircle width={16} height={16} /> : <IconClipboard width={16} height={16} />}
</button>
</div>

View File

@@ -25,7 +25,7 @@ import { ContentDiff } from "./content-diff"
import { ContentText } from "./content-text"
import { ContentBash } from "./content-bash"
import { ContentError } from "./content-error"
import { formatCount, formatDuration, formatNumber, normalizeLocale, useShareMessages } from "../share/common"
import { formatDuration } from "../share/common"
import { ContentMarkdown } from "./content-markdown"
import type { MessageV2 } from "opencode/session/message-v2"
import type { Diagnostic } from "vscode-languageserver-types"
@@ -44,7 +44,6 @@ export interface PartProps {
export function Part(props: PartProps) {
const [copied, setCopied] = createSignal(false)
const id = createMemo(() => props.message.id + "-" + props.index)
const messages = useShareMessages()
return (
<div
@@ -56,7 +55,7 @@ export function Part(props: PartProps) {
data-copied={copied() ? true : undefined}
>
<div data-component="decoration">
<div data-slot="anchor" title={messages.link_to_message}>
<div data-slot="anchor" title="Link to this message">
<a
href={`#${id()}`}
onClick={(e) => {
@@ -127,7 +126,7 @@ export function Part(props: PartProps) {
<IconHashtag width={18} height={18} />
<IconCheckCircle width={18} height={18} />
</a>
<span data-slot="tooltip">{messages.copied}</span>
<span data-slot="tooltip">Copied!</span>
</div>
<div data-slot="bar"></div>
</div>
@@ -144,13 +143,11 @@ export function Part(props: PartProps) {
</div>
{props.last && props.message.role === "assistant" && props.message.time.completed && (
<Footer
title={DateTime.fromMillis(props.message.time.completed)
.setLocale(normalizeLocale(messages.locale))
.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
title={DateTime.fromMillis(props.message.time.completed).toLocaleString(
DateTime.DATETIME_FULL_WITH_SECONDS,
)}
>
{DateTime.fromMillis(props.message.time.completed)
.setLocale(normalizeLocale(messages.locale))
.toLocaleString(DateTime.DATETIME_MED)}
{DateTime.fromMillis(props.message.time.completed).toLocaleString(DateTime.DATETIME_MED)}
</Footer>
)}
</div>
@@ -158,13 +155,13 @@ export function Part(props: PartProps) {
{props.message.role === "assistant" && props.part.type === "reasoning" && (
<div data-component="tool">
<div data-component="tool-title">
<span data-slot="name">{messages.thinking}</span>
<span data-slot="name">Thinking</span>
</div>
<Show when={props.part.text}>
<div data-component="assistant-reasoning">
<ResultsButton showCopy={messages.show_details} hideCopy={messages.hide_details}>
<ResultsButton showCopy="Show details" hideCopy="Hide details">
<div data-component="assistant-reasoning-markdown">
<ContentMarkdown expand text={props.part.text || messages.thinking_pending} />
<ContentMarkdown expand text={props.part.text || "Thinking..."} />
</div>
</ResultsButton>
</div>
@@ -173,7 +170,13 @@ export function Part(props: PartProps) {
)}
{props.message.role === "user" && props.part.type === "file" && (
<div data-component="attachment">
<div data-slot="copy">{messages.attachment}</div>
<div data-slot="copy">Attachment</div>
<div data-slot="filename">{props.part.filename}</div>
</div>
)}
{props.message.role === "user" && props.part.type === "file" && (
<div data-component="attachment">
<div data-slot="copy">Attachment</div>
<div data-slot="filename">{props.part.filename}</div>
</div>
)}
@@ -185,7 +188,7 @@ export function Part(props: PartProps) {
)}
{props.part.type === "tool" && props.part.state.status === "error" && (
<div data-component="tool" data-tool="error">
<ContentError>{formatErrorString(props.part.state.error, messages.error)}</ContentError>
<ContentError>{formatErrorString(props.part.state.error)}</ContentError>
<Spacer />
</div>
)}
@@ -340,45 +343,43 @@ function getShikiLang(filename: string) {
return type ? (overrides[type] ?? type) : "plaintext"
}
function getDiagnostics(
diagnosticsByFile: Record<string, Diagnostic[]>,
currentFile: string,
label: string,
): JSX.Element[] {
function getDiagnostics(diagnosticsByFile: Record<string, Diagnostic[]>, currentFile: string): JSX.Element[] {
const result: JSX.Element[] = []
if (diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined) return result
for (const d of diagnosticsByFile[currentFile]) {
if (d.severity !== 1) continue
for (const diags of Object.values(diagnosticsByFile)) {
for (const d of diags) {
if (d.severity !== 1) continue
const line = d.range.start.line + 1
const column = d.range.start.character + 1
const line = d.range.start.line + 1
const column = d.range.start.character + 1
result.push(
<pre>
<span data-color="red" data-marker="label">
{label}
</span>
<span data-color="dimmed" data-separator>
[{line}:{column}]
</span>
<span>{d.message}</span>
</pre>,
)
result.push(
<pre>
<span data-color="red" data-marker="label">
Error
</span>
<span data-color="dimmed" data-separator>
[{line}:{column}]
</span>
<span>{d.message}</span>
</pre>,
)
}
}
return result
}
function formatErrorString(error: string, label: string): JSX.Element {
function formatErrorString(error: string): JSX.Element {
const errorMarker = "Error: "
const startsWithError = error.startsWith(errorMarker)
return startsWithError ? (
<pre>
<span data-color="red" data-marker="label" data-separator>
{label}
Error
</span>
<span>{error.slice(errorMarker.length)}</span>
</pre>
@@ -390,7 +391,6 @@ function formatErrorString(error: string, label: string): JSX.Element {
}
export function TodoWriteTool(props: ToolProps) {
const messages = useShareMessages()
const priority: Record<Todo["status"], number> = {
in_progress: 0,
pending: 1,
@@ -406,9 +406,9 @@ export function TodoWriteTool(props: ToolProps) {
<>
<div data-component="tool-title">
<span data-slot="name">
<Switch fallback={messages.updating_plan}>
<Match when={starting()}>{messages.creating_plan}</Match>
<Match when={finished()}>{messages.completing_plan}</Match>
<Switch fallback="Updating plan">
<Match when={starting()}>Creating plan</Match>
<Match when={finished()}>Completing plan</Match>
</Switch>
</span>
</div>
@@ -429,8 +429,6 @@ export function TodoWriteTool(props: ToolProps) {
}
export function GrepTool(props: ToolProps) {
const messages = useShareMessages()
return (
<>
<div data-component="tool-title">
@@ -441,12 +439,7 @@ export function GrepTool(props: ToolProps) {
<Switch>
<Match when={props.state.metadata?.matches && props.state.metadata?.matches > 0}>
<ResultsButton
showCopy={formatCount(
props.state.metadata?.matches || 0,
messages.locale,
messages.match_one,
messages.match_other,
)}
showCopy={props.state.metadata?.matches === 1 ? "1 match" : `${props.state.metadata?.matches} matches`}
>
<ContentText expand compact text={props.state.output} />
</ResultsButton>
@@ -489,8 +482,6 @@ export function ListTool(props: ToolProps) {
}
export function WebFetchTool(props: ToolProps) {
const messages = useShareMessages()
return (
<>
<div data-component="tool-title">
@@ -500,7 +491,7 @@ export function WebFetchTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<ContentError>{formatErrorString(props.state.output, messages.error)}</ContentError>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={props.state.output}>
<ResultsButton>
@@ -514,7 +505,6 @@ export function WebFetchTool(props: ToolProps) {
}
export function ReadTool(props: ToolProps) {
const messages = useShareMessages()
const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd))
return (
@@ -528,10 +518,10 @@ export function ReadTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<ContentError>{formatErrorString(props.state.output, messages.error)}</ContentError>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={typeof props.state.metadata?.preview === "string"}>
<ResultsButton showCopy={messages.show_preview} hideCopy={messages.hide_preview}>
<ResultsButton showCopy="Show preview" hideCopy="Hide preview">
<ContentCode lang={getShikiLang(filePath() || "")} code={props.state.metadata?.preview} />
</ResultsButton>
</Match>
@@ -547,11 +537,8 @@ export function ReadTool(props: ToolProps) {
}
export function WriteTool(props: ToolProps) {
const messages = useShareMessages()
const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd))
const diagnostics = createMemo(() =>
getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath, messages.error),
)
const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath))
return (
<>
@@ -567,10 +554,10 @@ export function WriteTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<ContentError>{formatErrorString(props.state.output, messages.error)}</ContentError>
<ContentError>{formatErrorString(props.state.output)}</ContentError>
</Match>
<Match when={props.state.input?.content}>
<ResultsButton showCopy={messages.show_contents} hideCopy={messages.hide_contents}>
<ResultsButton showCopy="Show contents" hideCopy="Hide contents">
<ContentCode lang={getShikiLang(filePath() || "")} code={props.state.input?.content} />
</ResultsButton>
</Match>
@@ -581,11 +568,8 @@ export function WriteTool(props: ToolProps) {
}
export function EditTool(props: ToolProps) {
const messages = useShareMessages()
const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd))
const diagnostics = createMemo(() =>
getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath, messages.error),
)
const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath))
return (
<>
@@ -598,7 +582,7 @@ export function EditTool(props: ToolProps) {
<div data-component="tool-result">
<Switch>
<Match when={props.state.metadata?.error}>
<ContentError>{formatErrorString(props.state.metadata?.message || "", messages.error)}</ContentError>
<ContentError>{formatErrorString(props.state.metadata?.message || "")}</ContentError>
</Match>
<Match when={props.state.metadata?.diff}>
<div data-component="diff">
@@ -625,8 +609,6 @@ export function BashTool(props: ToolProps) {
}
export function GlobTool(props: ToolProps) {
const messages = useShareMessages()
return (
<>
<div data-component="tool-title">
@@ -637,12 +619,7 @@ export function GlobTool(props: ToolProps) {
<Match when={props.state.metadata?.count && props.state.metadata?.count > 0}>
<div data-component="tool-result">
<ResultsButton
showCopy={formatCount(
props.state.metadata?.count || 0,
messages.locale,
messages.result_one,
messages.result_other,
)}
showCopy={props.state.metadata?.count === 1 ? "1 result" : `${props.state.metadata?.count} results`}
>
<ContentText expand compact text={props.state.output} />
</ResultsButton>
@@ -662,12 +639,11 @@ interface ResultsButtonProps extends ParentProps {
}
function ResultsButton(props: ResultsButtonProps) {
const [show, setShow] = createSignal(false)
const messages = useShareMessages()
return (
<>
<button type="button" data-component="button-text" data-more onClick={() => setShow((e) => !e)}>
<span>{show() ? props.hideCopy || messages.hide_results : props.showCopy || messages.show_results}</span>
<span>{show() ? props.hideCopy || "Hide results" : props.showCopy || "Show results"}</span>
<span data-slot="icon">
<Show when={show()} fallback={<IconChevronRight width={11} height={11} />}>
<IconChevronDown width={11} height={11} />
@@ -692,19 +668,10 @@ function Footer(props: ParentProps<{ title: string }>) {
}
function ToolFooter(props: { time: number }) {
const messages = useShareMessages()
return (
props.time > MIN_DURATION && (
<Footer title={`${formatNumber(props.time, messages.locale)}ms`}>
{formatDuration(props.time, messages.locale)}
</Footer>
)
)
return props.time > MIN_DURATION && <Footer title={`${props.time}ms`}>{formatDuration(props.time)}</Footer>
}
function TaskTool(props: ToolProps) {
const messages = useShareMessages()
return (
<>
<div data-component="tool-title">
@@ -712,7 +679,7 @@ function TaskTool(props: ToolProps) {
<span data-slot="target">{props.state.input.description}</span>
</div>
<div data-component="tool-input">&ldquo;{props.state.input.prompt}&rdquo;</div>
<ResultsButton showCopy={messages.show_output} hideCopy={messages.hide_output}>
<ResultsButton showCopy="Show output" hideCopy="Hide output">
<div data-component="tool-output">
<ContentMarkdown expand text={props.state.output} />
</div>
@@ -733,7 +700,7 @@ export function FallbackTool(props: ToolProps) {
<>
<div></div>
<div>{arg[0]}</div>
<div>{String(arg[1] ?? "")}</div>
<div>{arg[1]}</div>
</>
)}
</For>
@@ -753,11 +720,10 @@ export function FallbackTool(props: ToolProps) {
// Converts nested objects/arrays into [path, value] pairs.
// E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]]
function flattenToolArgs(obj: unknown, prefix: string = ""): Array<[string, unknown]> {
const entries: Array<[string, unknown]> = []
if (typeof obj !== "object" || obj === null) return entries
function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
const entries: Array<[string, any]> = []
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
if (value !== null && typeof value === "object") {

View File

@@ -1,16 +1,7 @@
import { defineCollection, z } from "astro:content"
import { docsLoader, i18nLoader } from "@astrojs/starlight/loaders"
import { docsSchema, i18nSchema } from "@astrojs/starlight/schema"
import en from "./content/i18n/en.json"
const custom = Object.fromEntries(Object.keys(en).map((key) => [key, z.string()]))
import { defineCollection } from "astro:content"
import { docsLoader } from "@astrojs/starlight/loaders"
import { docsSchema } from "@astrojs/starlight/schema"
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
i18n: defineCollection({
loader: i18nLoader(),
schema: i18nSchema({
extend: z.object(custom).catchall(z.string()),
}),
}),
}

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