Compare commits
3 Commits
dev
...
brendan/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c4d0a0d0 | ||
|
|
7c6d82b79a | ||
|
|
3d63f86d19 |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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
@@ -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
|
||||
86
.github/workflows/compliance-close.yml
vendored
@@ -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`);
|
||||
}
|
||||
8
.github/workflows/daily-issues-recap.yml
vendored
@@ -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:
|
||||
|
||||
12
.github/workflows/daily-pr-recap.yml
vendored
@@ -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
|
||||
|
||||
82
.github/workflows/docs-locale-sync.yml
vendored
@@ -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"
|
||||
82
.github/workflows/duplicate-issues.yml
vendored
@@ -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."
|
||||
|
||||
3
.github/workflows/nix-hashes.yml
vendored
@@ -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:
|
||||
|
||||
96
.github/workflows/vouch-check-issue.yml
vendored
@@ -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}`);
|
||||
93
.github/workflows/vouch-check-pr.yml
vendored
@@ -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}`);
|
||||
37
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -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 }}
|
||||
@@ -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
@@ -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=="],
|
||||
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ stdenvNoCC.mkDerivation {
|
||||
../bun.lock
|
||||
../package.json
|
||||
../patches
|
||||
../install # required by desktop build (cli.rs include_str!)
|
||||
../install
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? ""}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
7
packages/desktop/src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
863
packages/desktop/src-tauri/src/ssh.rs
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 513 KiB |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
231
packages/ui/src/theme/themes/deltarune.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
232
packages/ui/src/theme/themes/undertale.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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/" },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>© <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>
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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 column’s 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>—</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>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Input Tokens</span>
|
||||
{data().tokens.input ? <span>{data().tokens.input}</span> : <span data-placeholder>—</span>}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Output Tokens</span>
|
||||
{data().tokens.output ? (
|
||||
<span>{data().tokens.output}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</span>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Reasoning Tokens</span>
|
||||
{data().tokens.reasoning ? (
|
||||
<span>{data().tokens.reasoning}</span>
|
||||
) : (
|
||||
<span data-placeholder>—</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>—</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>—</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>—</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>—</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>—</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,
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">“{props.state.input.prompt}”</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") {
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||