Compare commits

..
Author SHA1 Message Date
Aiden Cline 80c6123d2c update packages to v6 2026-02-13 14:49:33 -06:00
467 changed files with 12189 additions and 24839 deletions
-15
View File
@@ -1,15 +0,0 @@
adamdotdevin
Brendonovich
fwang
Hona
iamdavidhill
jayair
jlongster
kitlangton
kommander
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr
+4 -6
View File
@@ -3,13 +3,11 @@ description: "Setup Bun with caching and install dependencies"
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Cache Bun dependencies - name: Mount Bun Cache
uses: actions/cache@v4 uses: useblacksmith/stickydisk@v1
with: with:
path: ~/.bun/install/cache key: ${{ github.repository }}-bun-cache-${{ runner.os }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} path: ~/.bun
restore-keys: |
${{ runner.os }}-bun-
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
+1 -23
View File
@@ -1,29 +1,7 @@
### Issue for this PR
Closes #
### Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
### What does this PR do? ### What does this PR do?
Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
### How did you verify your code works? ### How did you verify your code works?
### Screenshots / recordings
_If this is a UI change, please include a screenshot or recording._
### Checklist
- [ ] I have tested my changes locally
- [ ] I have not included unrelated changes in this PR
_If you do not follow this template your PR will be automatically rejected._
+63 -85
View File
@@ -2,11 +2,10 @@ name: duplicate-issues
on: on:
issues: issues:
types: [opened, edited] types: [opened]
jobs: jobs:
check-duplicates: check-duplicates:
if: github.event.action == 'opened'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: read contents: read
@@ -17,6 +16,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode - name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
@@ -26,114 +27,91 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: | OPENCODE_PERMISSION: |
{ {
"bash": "deny", "bash": {
"webfetch": "deny", "*": "deny",
"edit": "deny", "gh issue*": "allow"
"write": "deny" },
"webfetch": "deny"
} }
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: | run: |
ISSUE_TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq .title) opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
ISSUE_BODY=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq .body)
PROMPT=$(cat <<EOF Issue number: ${{ github.event.issue.number }}
Check this new issue for compliance and duplicates:
CURRENT_ISSUE_NUMBER: $ISSUE_NUMBER Lookup this issue with gh issue view ${{ github.event.issue.number }}.
Title: $ISSUE_TITLE You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
Description: ---
$ISSUE_BODY
EOF
)
COMMENT=$(opencode run --agent duplicate-issue "$PROMPT") TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
if [ "$COMMENT" = "No action required" ]; then Check whether the issue follows our contributing guidelines and issue templates.
exit 0
fi
BODY="_The following comment was made by an LLM, it may be inaccurate:_ This project has three issue templates that every issue MUST use one of:
$COMMENT" 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
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$BODY" 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
if [[ "$COMMENT" == *"<!-- issue-compliance -->"* ]]; then 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.
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label needs:compliance
fi
recheck-compliance: ---
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install opencode TASK 2: DUPLICATE CHECK
run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
env: Consider:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} 1. Similar titles or descriptions
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 2. Same error messages or symptoms
OPENCODE_PERMISSION: | 3. Related functionality or components
{ 4. Similar feature requests
"bash": "deny",
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: |
ISSUE_TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq .title)
ISSUE_BODY=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq .body)
PROMPT=$(cat <<EOF Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
Recheck this edited issue for compliance:
MODE: recheck-compliance ---
CURRENT_ISSUE_NUMBER: $ISSUE_NUMBER
Title: $ISSUE_TITLE POSTING YOUR COMMENT:
Description: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
$ISSUE_BODY
EOF
)
COMMENT=$(opencode run --agent duplicate-issue "$PROMPT") 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 [ "$COMMENT" = "No action required" ]; then If duplicates were found, include a section about potential duplicates with links.
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label needs:compliance || true
IDS=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments" --jq '.[] | select(.body | contains("<!-- issue-compliance -->")) | .id') If the issue mentions keybinds/keyboard shortcuts, include a note about #4997.
for id in $IDS; do
gh api -X DELETE "repos/$REPO/issues/comments/$id"
done
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "Thanks for updating your issue. It now meets our contributing guidelines. :+1:" If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all.
exit 0
fi
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label needs:compliance Use this format for the comment:
BODY="_The following comment was made by an LLM, it may be inaccurate:_ [If not compliant:]
<!-- issue-compliance -->
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
$COMMENT" **What needs to be fixed:**
- [specific reasons]
EXISTING=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments" --jq '[.[] | select(.body | contains("<!-- issue-compliance -->")) | .id] | last // empty') Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
if [ -n "$EXISTING" ]; then
gh api -X PATCH "repos/$REPO/issues/comments/$EXISTING" -f body="$BODY"
exit 0
fi
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$BODY" [If duplicates found, add:]
---
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
[End with if not compliant:]
If you believe this was flagged incorrectly, please let a maintainer know.
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
@@ -0,0 +1,46 @@
name: nix-desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
nix-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L
-95
View File
@@ -1,95 +0,0 @@
name: nix-eval
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
nix-eval:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
set -euo pipefail
nix --version
echo "=== Flake metadata ==="
nix flake metadata
echo ""
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
echo ""
echo "=== Evaluating packages for all systems ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for packages.$system.$pkg"
echo "$output"
exit 1
fi
done
done
echo ""
echo "=== Evaluating optional packages ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $OPTIONAL_PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::warning::Evaluation failed for packages.$system.$pkg"
echo "$output"
fi
done
done
echo ""
echo "=== Evaluating devShells for all systems ==="
for system in $SYSTEMS; do
printf "%s: " "$system"
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for devShells.$system.default"
echo "$output"
exit 1
fi
done
echo ""
echo "=== All evaluations passed ==="
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [dev, beta] branches: [dev]
paths: paths:
- "bun.lock" - "bun.lock"
- "package.json" - "package.json"
+11 -16
View File
@@ -6,6 +6,17 @@ on:
jobs: jobs:
check-duplicates: check-duplicates:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: read contents: read
@@ -16,31 +27,16 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check team membership
id: team-check
run: |
LOGIN="${{ github.event.pull_request.user.login }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
echo "is_team=true" >> "$GITHUB_OUTPUT"
echo "Skipping: $LOGIN is a team member or bot"
else
echo "is_team=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun - name: Setup Bun
if: steps.team-check.outputs.is_team != 'true'
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Install dependencies - name: Install dependencies
if: steps.team-check.outputs.is_team != 'true'
run: bun install run: bun install
- name: Install opencode - name: Install opencode
if: steps.team-check.outputs.is_team != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt - name: Build prompt
if: steps.team-check.outputs.is_team != 'true'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -57,7 +53,6 @@ jobs:
} > pr_info.txt } > pr_info.txt
- name: Check for duplicate PRs - name: Check for duplicate PRs
if: steps.team-check.outputs.is_team != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-224
View File
@@ -16,11 +16,9 @@ jobs:
github.event.pull_request.user.login != 'fwang' && github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' && github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' && github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'R44VC0RP' &&
github.event.pull_request.user.login != 'opencode-agent[bot]' github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
pull-requests: write pull-requests: write
steps: steps:
- name: Check PR standards - name: Check PR standards
@@ -28,30 +26,6 @@ jobs:
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
const login = pr.user.login;
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
const cutoff = new Date('2026-02-19T00:00:00Z');
const prCreated = new Date(pr.created_at);
if (prCreated < cutoff) {
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
return;
}
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const title = pr.title; const title = pr.title;
async function addLabel(label) { async function addLabel(label) {
@@ -163,201 +137,3 @@ jobs:
await removeLabel('needs:issue'); await removeLabel('needs:issue');
console.log('PR meets all standards'); console.log('PR meets all standards');
check-compliance:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR template compliance
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
const cutoff = new Date('2026-02-19T00:00:00Z');
const prCreated = new Date(pr.created_at);
if (prCreated < cutoff) {
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
return;
}
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const body = pr.body || '';
const title = pr.title;
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
const issues = [];
// Check: template sections exist
const hasWhatSection = /### What does this PR do\?/.test(body);
const hasTypeSection = /### Type of change/.test(body);
const hasVerifySection = /### How did you verify your code works\?/.test(body);
const hasChecklistSection = /### Checklist/.test(body);
const hasIssueSection = /### Issue for this PR/.test(body);
if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
}
// Check: "What does this PR do?" has real content (not just placeholder text)
if (hasWhatSection) {
const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
const whatContent = whatMatch ? whatMatch[1].trim() : '';
const placeholder = 'Please provide a description of the issue';
const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
if (!whatContent || onlyPlaceholder) {
issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
}
}
// Check: at least one "Type of change" checkbox is checked
if (hasTypeSection) {
const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
const typeContent = typeMatch ? typeMatch[1] : '';
const hasCheckedBox = /- \[x\]/i.test(typeContent);
if (!hasCheckedBox) {
issues.push('No "Type of change" checkbox is checked. Please select at least one.');
}
}
// Check: issue reference (skip for docs/refactor)
if (!isDocsOrRefactor && hasIssueSection) {
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : '';
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
if (!hasIssueRef) {
issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
}
}
// Check: "How did you verify" has content
if (hasVerifySection) {
const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
if (!verifyContent) {
issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
}
}
// Check: checklist boxes are checked
if (hasChecklistSection) {
const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
const checklistContent = checklistMatch ? checklistMatch[1] : '';
const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
if (checked < 2) {
issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
}
}
// Helper functions
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {}
}
const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
if (issues.length > 0) {
// Non-compliant
if (!hasComplianceLabel) {
await addLabel('needs:compliance');
}
const marker = '<!-- issue-compliance -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(marker));
const body_text = `${marker}
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
**What needs to be fixed:**
${issues.map(i => `- ${i}`).join('\n')}
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
If you believe this was flagged incorrectly, please let a maintainer know.`;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body_text
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: body_text
});
}
console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
} else if (hasComplianceLabel) {
// Was non-compliant, now fixed
await removeLabel('needs:compliance');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const marker = '<!-- issue-compliance -->';
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id
});
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
});
console.log(`PR #${pr.number} is now compliant, label removed`);
} else {
console.log(`PR #${pr.number} is compliant`);
}
+6 -18
View File
@@ -137,7 +137,7 @@ jobs:
if: contains(matrix.settings.host, 'ubuntu') if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/apt-cache path: /var/cache/apt/archives
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }} key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt- ${{ runner.os }}-${{ matrix.settings.target }}-apt-
@@ -145,10 +145,8 @@ jobs:
- name: install dependencies (ubuntu only) - name: install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu') if: contains(matrix.settings.host, 'ubuntu')
run: | run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo chmod -R a+rw ~/apt-cache
- name: install Rust stable - name: install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -171,23 +169,13 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch - name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu') if: contains(matrix.settings.host, 'ubuntu')
with: run: |
tool: tauri-cli cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
git: https://github.com/tauri-apps/tauri echo "Installed tauri-cli version:"
# branch: feat/truly-portable-appimage cargo tauri --version
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Build and upload artifacts - name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
-98
View File
@@ -1,98 +0,0 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#E67E22"
tools:
"*": false
"github-issue-search": true
---
You are a duplicate issue detection agent. When an issue is opened, your job is to search for potentially duplicate or related open issues.
You have two jobs:
1. Check if the issue follows our issue templates/contributing requirements.
2. Check for potential duplicate issues.
Use the github-issue-search tool to find potentially related issues.
IMPORTANT: The input will contain a line `CURRENT_ISSUE_NUMBER: NNNN`. Never mark that issue as a duplicate of itself.
The input may also contain `MODE: recheck-compliance`.
- When MODE is `recheck-compliance`, ONLY run compliance checks. Do not run duplicate checks. Do not include duplicate or keybind sections.
- When MODE is missing, do the full opened-issue behavior (compliance + duplicates + keybind note).
## Compliance checks
This project has three issue templates:
1. Bug Report - needs a Description field with real content.
2. Feature Request - title should start with `[FEATURE]:` and include verification checkbox + meaningful description.
3. Question - needs a Question field with real content.
Also check:
- no AI-generated walls of text
- required sections are not placeholder-only / unchanged template text
- bug reports include some repro context
- feature requests explain the problem/need
- encourage system information where relevant
Do not be nitpicky about optional fields. Only flag real issues (missing template/required content, placeholder-only content, obviously AI-generated wall of text, empty/nonsensical issue).
## Duplicate checks
Search for duplicates by trying multiple keyword combinations from the issue title/body. Prioritize:
- similar title/description
- same error/symptoms
- same component/feature area
If the issue mentions keybinds, keyboard shortcuts, or key bindings, include a note to check pinned issue #4997.
## Output rules
If MODE is `recheck-compliance` and the issue is compliant, output exactly:
No action required
If MODE is missing and the issue is compliant AND no duplicates are found AND no keybind note is needed, output exactly:
No action required
Otherwise output exactly one markdown comment body with this structure:
- In `recheck-compliance` mode: include only the non-compliant section and ending note.
- In default mode: include sections as needed (non-compliant, duplicates, keybind).
- If non-compliant, start with:
<!-- issue-compliance -->
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
**What needs to be fixed:**
- [specific reason]
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
- If duplicates were found, add:
---
This issue might be a duplicate of existing issues. Please check:
- #1234: [brief reason]
- If keybind-related, add:
For keybind-related issues, please also check our pinned keybinds documentation: #4997
- If non-compliant, end with:
If you believe this was flagged incorrectly, please let a maintainer know.
Keep output concise. Do not wrap output in code fences.
-2
View File
@@ -359,7 +359,6 @@ opencode serve --hostname 0.0.0.0 --port 4096
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>] opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode session [command] opencode session [command]
opencode session list opencode session list
opencode session delete <sessionID>
opencode stats opencode stats
opencode uninstall opencode uninstall
opencode upgrade opencode upgrade
@@ -599,7 +598,6 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
OPENCODE_EXPERIMENTAL_OXFMT OPENCODE_EXPERIMENTAL_OXFMT
OPENCODE_EXPERIMENTAL_PLAN_MODE OPENCODE_EXPERIMENTAL_PLAN_MODE
OPENCODE_ENABLE_QUESTION_TOOL
OPENCODE_FAKE_VCS OPENCODE_FAKE_VCS
OPENCODE_GIT_BASH_PATH OPENCODE_GIT_BASH_PATH
OPENCODE_MODEL OPENCODE_MODEL
+8 -70
View File
@@ -1,7 +1,7 @@
--- ---
mode: primary mode: primary
hidden: true hidden: true
model: opencode/minimax-m2.5 model: opencode/claude-haiku-4-5
color: "#44BA81" color: "#44BA81"
tools: tools:
"*": false "*": false
@@ -12,8 +12,6 @@ You are a triage agent responsible for triaging github issues.
Use your github-triage tool to triage issues. Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
## Labels ## Labels
### windows ### windows
@@ -45,34 +43,12 @@ Desktop app issues:
**Only** add if the issue explicitly mentions nix. **Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen #### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". **Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs #### docs
Add if the issue requests better documentation or docs updates. Add if the issue requests better documentation or docs updates.
@@ -90,51 +66,13 @@ TUI issues potentially caused by our underlying TUI library:
When assigning to people here are the following rules: When assigning to people here are the following rules:
Desktop / Web: adamdotdev:
Use for desktop-labeled issues only. ONLY assign adam if the issue will have the "desktop" label.
- adamdotdevin fwang:
- iamdavidhill ONLY assign fwang if the issue will have the "zen" label.
- Brendonovich
- nexxeln
Zen: jayair:
ONLY assign if the issue will have the "zen" label. ONLY assign jayair if the issue will have the "docs" label.
- fwang In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)
+3
View File
@@ -16,12 +16,15 @@ wip:
For anything in the packages/web use the docs: prefix. For anything in the packages/web use the docs: prefix.
For anything in the packages/app use the ignore: prefix.
prefer to explain WHY something was done from an end user perspective instead of prefer to explain WHY something was done from an end user perspective instead of
WHAT was done. WHAT was done.
do not do generic messages like "improved agent experience" be very specific do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made about what user facing changes were made
if there are changes do a git pull --rebase
if there are conflicts DO NOT FIX THEM. notify me and I will fix them if there are conflicts DO NOT FIX THEM. notify me and I will fix them
## GIT DIFF ## GIT DIFF
+3
View File
@@ -1,5 +1,8 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"provider": { "provider": {
"opencode": { "opencode": {
"options": {}, "options": {},
-57
View File
@@ -1,57 +0,0 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-issue-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
interface Issue {
title: string
html_url: string
}
export default tool({
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for issue titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
},
async execute(args) {
const owner = "anomalyco"
const repo = "opencode"
const page = Math.floor(args.offset / args.limit) + 1
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:issue state:open`)
const result = await githubFetch(
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
)
if (result.total_count === 0) {
return `No issues found matching "${args.query}"`
}
const issues = result.items as Issue[]
if (issues.length === 0) {
return `No other issues found matching "${args.query}"`
}
const formatted = issues.map((issue) => `${issue.title}\n${issue.html_url}`).join("\n\n")
return `Found ${result.total_count} issues (showing ${issues.length}):\n\n${formatted}`
},
})
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub issues by title and description.
This tool searches issues in the sst/opencode repository and returns LLM-friendly results including:
- Issue number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in issue titles or descriptions.
+26 -49
View File
@@ -1,22 +1,8 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
// import { Octokit } from "@octokit/rest"
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt" import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
function getIssueNumber(): number { function getIssueNumber(): number {
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10) const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
if (!issue) throw new Error("ISSUE_NUMBER env var not set") if (!issue) throw new Error("ISSUE_NUMBER env var not set")
@@ -43,69 +29,60 @@ export default tool({
description: DESCRIPTION, description: DESCRIPTION,
args: { args: {
assignee: tool.schema assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]]) .enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
.describe("The username of the assignee") .describe("The username of the assignee")
.default("rekram1-node"), .default("rekram1-node"),
labels: tool.schema labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) .array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
.describe("The labels(s) to add to the issue") .describe("The labels(s) to add to the issue")
.default([]), .default([]),
}, },
async execute(args) { async execute(args) {
const issue = getIssueNumber() const issue = getIssueNumber()
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "anomalyco" const owner = "anomalyco"
const repo = "opencode" const repo = "opencode"
const results: string[] = [] const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (labels.includes("nix") && !nix) { if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
labels = labels.filter((x) => x !== "nix") throw new Error("Only desktop issues should be assigned to adamdotdevin")
results.push("Dropped label: nix (issue does not mention nix)")
} }
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee if (args.assignee === "fwang" && !args.labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang")
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
} }
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander") throw new Error("Only opentui issues should be assigned to kommander")
} }
// await octokit.rest.issues.addAssignees({
// owner,
// repo,
// issue_number: issue,
// assignees: [args.assignee],
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST", method: "POST",
body: JSON.stringify({ assignees: [assignee] }), body: JSON.stringify({ assignees: [args.assignee] }),
}) })
results.push(`Assigned @${assignee} to issue #${issue}`) results.push(`Assigned @${args.assignee} to issue #${issue}`)
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
if (labels.length > 0) { if (labels.length > 0) {
// await octokit.rest.issues.addLabels({
// owner,
// repo,
// issue_number: issue,
// labels,
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST", method: "POST",
body: JSON.stringify({ labels }), body: JSON.stringify({ labels }),
}) })
results.push(`Added labels: ${labels.join(", ")}`) results.push(`Added labels: ${args.labels.join(", ")}`)
} }
return results.join("\n") return results.join("\n")
+85 -3
View File
@@ -1,6 +1,88 @@
Use this tool to assign and/or label a GitHub issue. Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules. You can assign the following users:
Pick the most fitting labels for the issue and assign one owner. - thdxr
- adamdotdevin
- fwang
- jayair
- kommander
- rekram1-node
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
You can use the following labels:
- nix
- opentui
- perf
- web
- zen
- docs
Always try to assign an issue, if in doubt, assign rekram1-node to it.
## Breakdown of responsibilities:
### thdxr
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
This relates to OpenCode server primarily but has overlap with just about anything
### adamdotdevin
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
### fwang
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
### jayair
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
### kommander
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
- random characters on screen
- keybinds not working on different terminals
- general terminal stuff
Then assign the issue to Him.
### rekram1-node
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
If no one else makes sense to assign, assign rekram1-node to it.
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
## Breakdown of Labels:
### nix
Any issue that mentions nix, or nixos should have a nix label
### opentui
Anything relating to the TUI itself should have an opentui label
### perf
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
### desktop
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
### zen
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
### docs
Anything related to the documentation should have a docs label
### windows
Use for any issue that involves the windows OS
@@ -1,5 +0,0 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
-9
View File
@@ -1,9 +0,0 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}
-1
View File
@@ -110,4 +110,3 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
-5
View File
@@ -24,11 +24,6 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on. Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
## Adding New Providers
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
## Developing OpenCode ## Developing OpenCode
- Requirements: Bun 1.3+ - Requirements: Bun 1.3+
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -32,8 +32,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -32,8 +32,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
-139
View File
@@ -1,139 +0,0 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">AI-агент для програмування з відкритим кодом.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Встановлення
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Менеджери пакетів
npm i -g opencode-ai@latest # або bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Будь-яка ОС
nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки
```
> [!TIP]
> Перед встановленням видаліть версії старші за 0.1.x.
### Десктопний застосунок (BETA)
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
| Платформа | Завантаження |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` або AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Каталог встановлення
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити)
4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням
```bash
# Приклади
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Агенти
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
- **plan** - Агент лише для читання для аналізу та дослідження коду
- За замовчуванням забороняє редагування файлів
- Запитує дозвіл перед запуском bash-команд
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
### Документація
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
### Внесок
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
### Проєкти на базі OpenCode
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
### FAQ
#### Чим це відрізняється від Claude Code?
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
- 100% open source
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
- Підтримка LSP з коробки
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
---
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
-6
View File
@@ -1,11 +1,5 @@
# Security # Security
## IMPORTANT
We do not accept AI generated security reports. We receive a large number of
these and we absolutely do not have the resources to review them all. If you
submit one that will be an automatic ban from the project.
## Threat Model ## Threat Model
### Overview ### Overview
+332 -441
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770812194, "lastModified": 1770073757,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=", "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8", "rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github" "type": "github"
}, },
"original": { "original": {
-1
View File
@@ -2,7 +2,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/* deno-fmt-ignore-file */ /* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../sst-env.d.ts" /> /// <reference path="../sst-env.d.ts" />
+1 -11
View File
@@ -145,16 +145,6 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS18"), new sst.Secret("ZEN_MODELS18"),
new sst.Secret("ZEN_MODELS19"), new sst.Secret("ZEN_MODELS19"),
new sst.Secret("ZEN_MODELS20"), new sst.Secret("ZEN_MODELS20"),
new sst.Secret("ZEN_MODELS21"),
new sst.Secret("ZEN_MODELS22"),
new sst.Secret("ZEN_MODELS23"),
new sst.Secret("ZEN_MODELS24"),
new sst.Secret("ZEN_MODELS25"),
new sst.Secret("ZEN_MODELS26"),
new sst.Secret("ZEN_MODELS27"),
new sst.Secret("ZEN_MODELS28"),
new sst.Secret("ZEN_MODELS29"),
new sst.Secret("ZEN_MODELS30"),
] ]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
@@ -214,9 +204,9 @@ new sst.cloudflare.x.SolidStart("Console", {
}, },
transform: { transform: {
server: { server: {
placement: { region: "aws:us-east-1" },
transform: { transform: {
worker: { worker: {
placement: { mode: "smart" },
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
}, },
}, },
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-7y6gQyIxyrdp2DaG/0oOEpuL+1n9oa8arUn1CuDiDhA=", "x86_64-linux": "sha256-FsFTitxnN2brebZDBRGJB0NWTOVYDa/QcNRH0ip/Gk4=",
"aarch64-linux": "sha256-7dnHO2WqQZ9A8cG3EC8p7408YR9n2F5C6DG5rNWHqNY=", "aarch64-linux": "sha256-knSEqEPyonBUfmGZKTq5Om4HikItWbfPdfT7p6iljzs=",
"aarch64-darwin": "sha256-jxjhnVfE61RVOHaWvDO4mGLk6guQ8jHeXv/pbu5nbaE=", "aarch64-darwin": "sha256-uRgWfuOlLECRCOszm8XhySiWxu9IdDhpSbosPZPAZVI=",
"x86_64-darwin": "sha256-22yM4FEtVxGWRug6H0rKog86Q/cYE3QsADrRbLeJKVQ=" "x86_64-darwin": "sha256-gHuA+Ud9L+XLvKm5Vp5jCXfZWOtunnmX/lB8vczHsG0="
} }
} }
+3 -7
View File
@@ -40,9 +40,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb", "ai": "6.0.86",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -69,11 +67,10 @@
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"husky": "9.1.7", "husky": "9.1.7",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "3.18.10", "sst": "3.17.23",
"turbo": "2.5.6" "turbo": "2.5.6"
}, },
"dependencies": { "dependencies": {
@@ -104,7 +101,6 @@
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
} }
} }
@@ -1,15 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await page.locator("main").click({ position: { x: 5, y: 5 } })
await expect(prompt).not.toBeFocused()
await page.keyboard.press("Control+L")
await expect(prompt).toBeFocused()
})
-31
View File
@@ -1,31 +0,0 @@
import { test, expect } from "../fixtures"
import { modKey } from "../utils"
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
const value = await el.getAttribute("aria-expanded")
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
return value === "true"
}
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
})
@@ -1,32 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await dialog.getByRole("textbox").first().fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" }).first()
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press(`${modKey}+W`)
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
})
+3 -9
View File
@@ -28,6 +28,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
const key = await target.getAttribute("data-key") const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item") if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
const model = key.split(":").slice(1).join(":") const model = key.split(":").slice(1).join(":")
await input.fill(model) await input.fill(model)
@@ -36,13 +37,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
await page.locator(promptSelector).click() const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
await page.keyboard.type("/model") await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialogAgain = page.getByRole("dialog")
await expect(dialogAgain).toBeVisible()
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
}) })
@@ -1,22 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
const dt = await page.evaluateHandle((text) => {
const dt = new DataTransfer()
dt.setData("text/plain", text)
return dt
}, `file:${path}`)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", path)
})
@@ -1,30 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
const dt = await page.evaluateHandle((b64) => {
const dt = new DataTransfer()
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const file = new File([bytes], "drop.png", { type: "image/png" })
dt.items.add(file)
return dt
}, png)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const img = page.locator('img[alt="drop.png"]').first()
await expect(img).toBeVisible()
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
await expect(remove).toBeVisible()
await img.hover()
await remove.click()
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
})
@@ -1,18 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
await gotoSession()
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})
@@ -1,23 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})
-3
View File
@@ -30,9 +30,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) => export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]` `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.6", "version": "1.1.65",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+64 -37
View File
@@ -1,36 +1,35 @@
import "@/index.css" import "@/index.css"
import { Code } from "@opencode-ai/ui/code" import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { I18nProvider } from "@opencode-ai/ui/context" import { Router, Route, Navigate } from "@solidjs/router"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Navigate, Route, Router } from "@solidjs/router" import { Font } from "@opencode-ai/ui/font"
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { CommandProvider } from "@/context/command" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CommentsProvider } from "@/context/comments" import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { FileProvider } from "@/context/file" import { I18nProvider } from "@opencode-ai/ui/context"
import { GlobalSDKProvider } from "@/context/global-sdk" import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { LayoutProvider } from "@/context/layout"
import { PromptProvider } from "@/context/prompt" import { GlobalSDKProvider } from "@/context/global-sdk"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server" import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout" import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
@@ -58,11 +57,7 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
} }
} }
@@ -112,6 +107,30 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
) )
} }
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
@@ -138,19 +157,27 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
<Show when={server.key} keyed> <Show when={server.url} keyed>
{props.children} {props.children}
</Show> </Show>
) )
} }
export function AppInterface(props: { export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
children?: JSX.Element const platform = usePlatform()
defaultServer: ServerConnection.Key const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
servers?: Array<ServerConnection.Any> const defaultServerUrl = resolveDefaultServerUrl({
}) { defaultUrl: props.defaultUrl,
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
@@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: {
}} }}
modal={false} modal={false}
placement="top-start" placement="top-start"
gutter={4} gutter={8}
> >
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children} {props.children}
@@ -1,18 +1,19 @@
import { Button } from "@opencode-ai/ui/button" import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { normalizeServerUrl, useServer } from "@/context/server"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk"
import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps { interface AddRowProps {
@@ -88,7 +89,7 @@ function useServerPreview(fetcher: typeof fetch) {
if (!looksComplete(value)) return if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return if (!normalized) return
const result = await checkServerHealth({ url: normalized }, fetcher) const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@@ -170,13 +171,14 @@ export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher) const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>, status: {} as Record<string, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
adding: false, adding: false,
@@ -212,25 +214,24 @@ export function DialogSelectServer() {
}) })
} }
const replaceServer = (original: ServerConnection.Http, next: string) => { const replaceServer = (original: string, next: string) => {
const active = server.key const active = server.url
const newConn = server.add(next) const nextActive = active === original ? next : active
if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active server.add(next)
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original)) server.remove(original)
} }
const items = createMemo(() => { const items = createMemo(() => {
const current = server.current const current = server.url
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)] return [current, ...list.filter((x) => x !== current)]
}) })
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const list = items()
@@ -245,17 +246,17 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (a === active) return -1
if (b === active) return 1 if (b === active) return 1
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
}) })
async function refreshHealth() { async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (conn) => { items().map(async (url) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) results[url] = await checkServerHealth(url, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -268,15 +269,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
async function select(conn: ServerConnection.Any, persist?: boolean) { async function select(value: string, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return if (!persist && store.status[value]?.healthy === false) return
dialog.close() dialog.close()
if (persist) { if (persist) {
server.add(conn.http.url) server.add(value)
navigate("/") navigate("/")
return return
} }
server.setActive(ServerConnection.key(conn)) server.setActive(value)
navigate("/") navigate("/")
} }
@@ -310,7 +311,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" }) setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher) const result = await checkServerHealth(normalized, fetcher)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
@@ -319,25 +320,25 @@ export function DialogSelectServer() {
} }
resetAdd() resetAdd()
await select({ type: "http", http: { url: normalized } }, true) await select(normalized, true)
} }
async function handleEdit(original: ServerConnection.Any, value: string) { async function handleEdit(original: string, value: string) {
if (store.editServer.busy || original.type !== "http") return if (store.editServer.busy) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) { if (!normalized) {
resetEdit() resetEdit()
return return
} }
if (normalized === original.http.url) { if (normalized === original) {
resetEdit() resetEdit()
return return
} }
setStore("editServer", { busy: true, error: "" }) setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher) const result = await checkServerHealth(normalized, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
@@ -365,7 +366,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url) handleAdd(store.addServer.url)
} }
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => { const handleEditKey = (event: KeyboardEvent, original: string) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
@@ -377,7 +378,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value) handleEdit(original, store.editServer.value)
} }
async function handleRemove(url: ServerConnection.Key) { async function handleRemove(url: string) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) { if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null) platform.setDefaultServerUrl?.(null)
@@ -389,14 +390,11 @@ export function DialogSelectServer() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}> <div ref={(el) => (listRoot = el)}>
<List <List
search={{ search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={sortedItems} items={sortedItems}
key={(x) => x.http.url} key={(x) => x}
onSelect={(x) => { onSelect={(x) => {
if (x) select(x) if (x) select(x)
}} }}
@@ -427,11 +425,10 @@ export function DialogSelectServer() {
} }
> >
{(i) => { {(i) => {
const key = ServerConnection.key(i)
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show <Show
when={store.editServer.id !== i.http.url} when={store.editServer.id !== i}
fallback={ fallback={
<EditRow <EditRow
value={store.editServer.value} value={store.editServer.value}
@@ -446,12 +443,12 @@ export function DialogSelectServer() {
} }
> >
<ServerRow <ServerRow
conn={i} url={i}
status={store.status[key]} status={store.status[i]}
dimmed={store.status[key]?.healthy === false} dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1" class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={ badge={
<Show when={defaultUrl() === i.http.url}> <Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -459,63 +456,59 @@ export function DialogSelectServer() {
} }
/> />
</Show> </Show>
<Show when={store.editServer.id !== i.http.url}> <Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4"> <div class="flex items-center justify-center gap-5 pl-4">
<Show when={ServerConnection.key(current()) === key}> <Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show> </Show>
<Show when={i.type === "http"}> <DropdownMenu>
<DropdownMenu> <DropdownMenu.Trigger
<DropdownMenu.Trigger as={IconButton}
as={IconButton} icon="dot-grid"
icon="dot-grid" variant="ghost"
variant="ghost" class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" onClick={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => e.stopPropagation()} onPointerDown={(e: PointerEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()} />
/> <DropdownMenu.Portal>
<DropdownMenu.Portal> <DropdownMenu.Content class="mt-1">
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Item
<DropdownMenu.Item onSelect={() => {
onSelect={() => { setStore("editServer", {
setStore("editServer", { id: i,
id: i.http.url, value: i,
value: i.http.url, error: "",
error: "", status: store.status[i]?.healthy,
status: store.status[ServerConnection.key(i)]?.healthy, })
}) }}
}} >
> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> </DropdownMenu.Item>
</DropdownMenu.Item> <Show when={canDefault() && defaultUrl() !== i}>
<Show when={canDefault() && defaultUrl() !== i.http.url}> <DropdownMenu.Item onSelect={() => setDefault(i)}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")} {language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </Show>
</DropdownMenu.Portal> <Show when={canDefault() && defaultUrl() === i}>
</DropdownMenu> <DropdownMenu.Item onSelect={() => setDefault(null)}>
</Show> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
</Show> </Show>
</div> </div>
+32 -101
View File
@@ -21,8 +21,6 @@ import {
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
function pathToFileUrl(filepath: string): string { function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}` return `file://${encodeFilePath(filepath)}`
} }
@@ -71,13 +69,13 @@ const kindLabel = (kind: Kind) => {
const kindTextColor = (kind: Kind) => { const kindTextColor = (kind: Kind) => {
if (kind === "add") return "color: var(--icon-diff-add-base)" if (kind === "add") return "color: var(--icon-diff-add-base)"
if (kind === "del") return "color: var(--icon-diff-delete-base)" if (kind === "del") return "color: var(--icon-diff-delete-base)"
return "color: var(--icon-diff-modified-base)" return "color: var(--icon-warning-active)"
} }
const kindDotColor = (kind: Kind) => { const kindDotColor = (kind: Kind) => {
if (kind === "add") return "background-color: var(--icon-diff-add-base)" if (kind === "add") return "background-color: var(--icon-diff-add-base)"
if (kind === "del") return "background-color: var(--icon-diff-delete-base)" if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
return "background-color: var(--icon-diff-modified-base)" return "background-color: var(--icon-warning-active)"
} }
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => { const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
@@ -262,20 +260,12 @@ export default function FileTree(props: {
_marks?: Set<string> _marks?: Set<string>
_deeps?: Map<string, number> _deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind> _kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) { }) {
const file = useFile() const file = useFile()
const level = props.level ?? 0 const level = props.level ?? 0
const draggable = () => props.draggable ?? true const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true const tooltip = () => props.tooltip ?? true
const key = (p: string) =>
file
.normalize(p)
.replace(/[\\/]+$/, "")
.replaceAll("\\", "/")
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
const filter = createMemo(() => { const filter = createMemo(() => {
if (props._filter) return props._filter if (props._filter) return props._filter
@@ -317,45 +307,23 @@ export default function FileTree(props: {
const out = new Map<string, number>() const out = new Map<string, number>()
const root = props.path const visit = (dir: string, lvl: number): number => {
if (!(file.tree.state(root)?.expanded ?? false)) return out const expanded = file.tree.state(dir)?.expanded ?? false
if (!expanded) return -1
const seen = new Set<string>() const nodes = file.tree.children(dir)
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] const max = nodes.reduce((max, node) => {
if (node.type !== "directory") return max
const open = file.tree.state(node.path)?.expanded ?? false
if (!open) return max
return Math.max(max, visit(node.path, lvl + 1))
}, lvl)
const push = (dir: string, lvl: number) => { out.set(dir, max)
const id = key(dir) return max
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
} }
visit(props.path, level - 1)
return out return out
}) })
@@ -447,13 +415,12 @@ export default function FileTree(props: {
}) })
return ( return (
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}> <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}> <For each={nodes()}>
{(node) => { {(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1 const deep = () => deeps().get(node.path) ?? -1
const kind = () => visibleKind(node, kinds(), marks()) const kind = () => visibleKind(node, kinds(), marks())
const active = () => !!kind() && !node.ignored
return ( return (
<Switch> <Switch>
@@ -492,27 +459,21 @@ export default function FileTree(props: {
}} }}
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`} style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
/> />
<Show <FileTree
when={level < MAX_DEPTH && !chain.includes(key(node.path))} path={node.path}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>} level={level + 1}
> allowed={props.allowed}
<FileTree modified={props.modified}
path={node.path} kinds={props.kinds}
level={level + 1} active={props.active}
allowed={props.allowed} draggable={props.draggable}
modified={props.modified} tooltip={props.tooltip}
kinds={props.kinds} onFileClick={props.onFileClick}
active={props.active} _filter={filter()}
draggable={props.draggable} _marks={marks()}
tooltip={props.tooltip} _deeps={deeps()}
onFileClick={props.onFileClick} _kinds={kinds()}
_filter={filter()} />
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
</Collapsible.Content> </Collapsible.Content>
</Collapsible> </Collapsible>
</Match> </Match>
@@ -531,37 +492,7 @@ export default function FileTree(props: {
onClick={() => props.onFileClick?.(node)} onClick={() => props.onFileClick?.(node)}
> >
<div class="w-4 shrink-0" /> <div class="w-4 shrink-0" />
<Switch> <FileIcon node={node} class="text-icon-weak size-4" />
<Match when={node.ignored}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style="color: var(--icon-weak-base)"
mono
/>
</Match>
<Match when={active()}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style={kindTextColor(kind()!)}
mono
/>
</Match>
<Match when={!node.ignored}>
<span class="filetree-iconpair size-4">
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
/>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
mono
/>
</span>
</Match>
</Switch>
</FileTreeNode> </FileTreeNode>
</FileTreeNodeTooltip> </FileTreeNodeTooltip>
</Match> </Match>
+213 -379
View File
@@ -1,5 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -26,13 +26,13 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model" import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -94,6 +94,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal() const local = useLocal()
const files = useFile() const files = useFile()
const prompt = usePrompt() const prompt = usePrompt()
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
const layout = useLayout() const layout = useLayout()
const comments = useComments() const comments = useComments()
const params = useParams() const params = useParams()
@@ -104,12 +105,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
const mirror = { input: false } const mirror = { input: false }
const inset = 44
const scrollCursorIntoView = () => { const scrollCursorIntoView = () => {
const container = scrollRef const container = scrollRef
@@ -119,14 +119,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return if (!editorRef.contains(range.startContainer)) return
const cursor = getCursorPosition(editorRef) const rect = range.getBoundingClientRect()
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
if (cursor >= length) {
container.scrollTop = container.scrollHeight
return
}
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
if (!rect.height) return if (!rect.height) return
const containerRect = container.getBoundingClientRect() const containerRect = container.getBoundingClientRect()
@@ -139,8 +132,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
if (bottom > container.scrollTop + container.clientHeight - inset) { if (bottom > container.scrollTop + container.clientHeight - padding) {
container.scrollTop = bottom - container.clientHeight + inset container.scrollTop = bottom - container.clientHeight + padding
} }
} }
@@ -230,26 +223,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
}) })
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: language.t(EXAMPLES[store.placeholder]),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const commentCount = createMemo(() => { const MAX_HISTORY = 100
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const contextItems = createMemo(() => {
const items = prompt.context.items()
if (store.mode !== "shell") return items
return items.filter((item) => !item.comment?.trim())
})
const hasUserPrompt = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
const messages = sync.data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})
const [history, setHistory] = persisted( const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]), Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{ createStore<{
@@ -267,18 +250,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}), }),
) )
const suggest = createMemo(() => !hasUserPrompt())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
suggest: suggest(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p) const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true) setStore("applyingHistory", true)
@@ -309,45 +280,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const isFocused = createFocusSignal(() => editorRef) const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click()
const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => editorRef?.focus())
}
const shellModeKey = "mod+shift+x"
const normalModeKey = "mod+shift+e"
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: store.mode !== "normal",
onSelect: pick,
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: shellModeKey,
disabled: store.mode === "shell",
onSelect: () => setMode("shell"),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: normalModeKey,
disabled: store.mode === "normal",
onSelect: () => setMode("normal"),
},
])
const closePopover = () => setStore("popover", null) const closePopover = () => setStore("popover", null)
@@ -393,7 +325,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(() => { createEffect(() => {
params.id params.id
if (params.id) return if (params.id) return
if (!suggest()) return
const interval = setInterval(() => { const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
}, 6500) }, 6500)
@@ -403,10 +334,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [composing, setComposing] = createSignal(false) const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => { createEffect(() => {
closePopover() if (!isFocused()) closePopover()
setComposing(false) })
}
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
const agentList = createMemo(() => const agentList = createMemo(() =>
sync.data.agent sync.data.agent
@@ -879,13 +815,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault()
if (store.mode !== "normal") return
pick()
return
}
if (event.key === "Backspace") { if (event.key === "Backspace") {
const selection = window.getSelection() const selection = window.getSelection()
if (selection && selection.isCollapsed) { if (selection && selection.isCollapsed) {
@@ -913,39 +842,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
} }
if (event.key === "Escape") {
if (store.popover) {
closePopover()
event.preventDefault()
event.stopPropagation()
return
}
if (store.mode === "shell") {
setStore("mode", "normal")
event.preventDefault()
event.stopPropagation()
return
}
if (working()) {
abort()
event.preventDefault()
event.stopPropagation()
return
}
if (escBlur()) {
editorRef.blur()
event.preventDefault()
event.stopPropagation()
return
}
}
if (store.mode === "shell") { if (store.mode === "shell") {
const { collapsed, cursorPosition, textLength } = getCaretState() const { collapsed, cursorPosition, textLength } = getCaretState()
if (event.key === "Escape") {
setStore("mode", "normal")
event.preventDefault()
return
}
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal") setStore("mode", "normal")
event.preventDefault() event.preventDefault()
@@ -1008,13 +911,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!collapsed) return if (!collapsed) return
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current())
const textContent = prompt const textContent = prompt
.current() .current()
.map((part) => ("content" in part ? part.content : "")) .map((part) => ("content" in part ? part.content : ""))
.join("") .join("")
const direction = event.key === "ArrowUp" ? "up" : "down" const direction = event.key === "ArrowUp" ? "up" : "down"
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
if (navigateHistory(direction)) { const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (direction === "up") {
if (!allowUp) return
if (navigateHistory("up")) {
event.preventDefault()
}
return
}
if (!allowDown) return
if (navigateHistory("down")) {
event.preventDefault() event.preventDefault()
} }
return return
@@ -1024,12 +945,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event) handleSubmit(event)
} }
if (event.key === "Escape") {
if (store.popover) {
closePopover()
} else if (working()) {
abort()
}
}
} }
const variants = createMemo(() => ["default", ...local.model.variant.list()])
return ( return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-0"> <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<PromptPopover <PromptPopover
popover={store.popover} popover={store.popover}
setSlashPopoverRef={(el) => (slashPopoverRef = el)} setSlashPopoverRef={(el) => (slashPopoverRef = el)}
@@ -1049,8 +975,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"group/prompt-input": true, "group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true, "rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null, "border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
@@ -1060,7 +986,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")} label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
/> />
<PromptContextItems <PromptContextItems
items={contextItems()} items={prompt.context.items()}
active={(item) => { active={(item) => {
const active = comments.active() const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1080,196 +1006,62 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onRemove={removeImageAttachment} onRemove={removeImageAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
/> />
<div <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
class="relative" <div
onMouseDown={(e) => { data-component="prompt-input"
const target = e.target ref={(el) => {
if (!(target instanceof HTMLElement)) return editorRef = el
if ( props.ref?.(el)
target.closest( }}
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', role="textbox"
) aria-multiline="true"
) { aria-label={placeholder()}
return contenteditable="true"
} autocapitalize="off"
editorRef?.focus() autocorrect="off"
}} spellcheck={false}
> onInput={handleInput}
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}> onPaste={handlePaste}
<div onCompositionStart={() => setComposing(true)}
data-component="prompt-input" onCompositionEnd={() => setComposing(false)}
ref={(el) => { onKeyDown={handleKeyDown}
editorRef = el classList={{
props.ref?.(el) "select-text": true,
}} "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
role="textbox" "[&_[data-type=file]]:text-syntax-property": true,
aria-multiline="true" "[&_[data-type=agent]]:text-syntax-type": true,
aria-label={placeholder()} "font-mono!": store.mode === "shell",
contenteditable="true" }}
autocapitalize="off" />
autocorrect="off" <Show when={!prompt.dirty()}>
spellcheck={false} <div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
onInput={handleInput} {placeholder()}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
>
{placeholder()}
</div>
</Show>
</div>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) addImageAttachment(file)
e.currentTarget.value = ""
}}
/>
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</div>
</div> </div>
</Show> </Show>
</div> </div>
</form> <div class="relative p-3 flex items-center justify-between gap-2">
<Show when={store.mode === "normal" || store.mode === "shell"}> <div class="flex items-center gap-2 min-w-0 flex-1">
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip"> <Switch>
<div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> <Match when={store.mode === "shell"}>
<div class="flex items-center gap-1.5 min-w-0 flex-1"> <div class="flex items-center gap-2 px-2 h-6">
<Show when={store.mode === "shell"}> <Icon name="console" size="small" class="text-icon-primary" />
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}> <span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> <span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span>
<div class="size-4 shrink-0" />
</div> </div>
</Show> </Match>
<Match when={store.mode === "normal"}>
<Show when={store.mode === "normal"}>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={8}
title={language.t("command.agent.cycle")} title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")} keybind={command.keybind("agent.cycle")}
> >
<Select <Select
size="normal"
options={agentNames()} options={agentNames()}
current={local.agent.current()?.name ?? ""} current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set} onSelect={local.agent.set}
class="capitalize max-w-[160px]" class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
valueClass="truncate text-13-regular" valueClass="truncate"
triggerStyle={{ height: "28px" }}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
@@ -1278,24 +1070,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={ fallback={
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={8}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<Button <Button
as="div" as="div"
variant="ghost" variant="ghost"
size="normal" class="px-2 min-w-0 max-w-[240px]"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show> </Show>
<span class="truncate"> <span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
@@ -1307,25 +1093,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={8}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<ModelSelectorPopover <ModelSelectorPopover
triggerAs={Button} triggerAs={Button}
triggerProps={{ triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
variant: "ghost",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show> </Show>
<span class="truncate"> <span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
@@ -1334,59 +1111,116 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</ModelSelectorPopover> </ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
<TooltipKeybind <Show when={local.model.variant.list().length > 0}>
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
<div class="shrink-0" data-component="prompt-mode-toggle">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
value={(mode) => mode}
label={(mode) => (
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={8}
openDelay={2000} title={language.t("command.model.variant.cycle")}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")} keybind={command.keybind("model.variant.cycle")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center"
> >
<Icon <Button
name={mode === "shell" ? "console" : "prompt"} data-action="model-variant-cycle"
class="size-[18px]" variant="ghost"
classList={{ class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
"text-icon-strong-base": store.mode === mode, onClick={() => local.model.variant.cycle()}
"text-icon-weak": store.mode !== mode, >
}} {local.model.variant.current() ?? language.t("common.default")}
/> </Button>
</TooltipKeybind> </TooltipKeybind>
)} </Show>
onSelect={(mode) => mode && setMode(mode)} <Show when={permission.permissionsEnabled() && params.id}>
fill <TooltipKeybind
pad="none" placement="top"
class="w-[68px]" gutter={8}
/> title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</Show>
</Match>
</Switch>
</div>
<div class="flex items-center gap-1 shrink-0">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) addImageAttachment(file)
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 mr-1">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<Button
type="button"
variant="ghost"
class="size-6 px-1"
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="photo" class="size-4.5" />
</Button>
</Tooltip>
</Show>
</div> </div>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
type="submit"
disabled={!prompt.dirty() && !working() && commentCount() === 0}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div> </div>
</div> </div>
</Show> </form>
</div> </div>
) )
} }
@@ -41,9 +41,10 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
> >
<div <div
classList={{ classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"hover:bg-surface-interactive-weak": !!item.commentID && !selected, "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected, "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
selected,
"bg-background-stronger": !selected, "bg-background-stronger": !selected,
}} }}
onClick={() => props.openComment(item)} onClick={() => props.openComment(item)}
@@ -73,7 +73,7 @@ describe("prompt-input history", () => {
expect(original[1].selection?.startLine).toBe(1) expect(original[1].selection?.startLine).toBe(1)
}) })
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => { test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
const value = "a\nb\nc" const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
@@ -85,16 +85,7 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
}) })
}) })
@@ -4,13 +4,11 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100 export const MAX_HISTORY = 100
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) { export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
if (!text.includes("\n")) return true
const position = Math.max(0, Math.min(cursor, text.length)) const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0 if (direction === "up") return !text.slice(0, position).includes("\n")
const atEnd = position === text.length return !text.slice(position).includes("\n")
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0
return position === text.length
} }
export function clonePromptParts(prompt: Prompt): Prompt { export function clonePromptParts(prompt: Prompt): Prompt {
@@ -9,40 +9,27 @@ describe("promptPlaceholder", () => {
mode: "shell", mode: "shell",
commentCount: 0, commentCount: 0,
example: "example", example: "example",
suggest: true,
t, t,
}) })
expect(value).toBe("prompt.placeholder.shell") expect(value).toBe("prompt.placeholder.shell")
}) })
test("returns summarize placeholders for comment context", () => { test("returns summarize placeholders for comment context", () => {
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe( expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
"prompt.placeholder.summarizeComment", "prompt.placeholder.summarizeComment",
) )
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe( expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
"prompt.placeholder.summarizeComments", "prompt.placeholder.summarizeComments",
) )
}) })
test("returns default placeholder with example when suggestions enabled", () => { test("returns default placeholder with example", () => {
const value = promptPlaceholder({ const value = promptPlaceholder({
mode: "normal", mode: "normal",
commentCount: 0, commentCount: 0,
example: "translated-example", example: "translated-example",
suggest: true,
t, t,
}) })
expect(value).toBe("prompt.placeholder.normal:translated-example") expect(value).toBe("prompt.placeholder.normal:translated-example")
}) })
test("returns simple placeholder when suggestions disabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: false,
t,
})
expect(value).toBe("prompt.placeholder.simple")
})
}) })
@@ -2,7 +2,6 @@ type PromptPlaceholderInput = {
mode: "normal" | "shell" mode: "normal" | "shell"
commentCount: number commentCount: number
example: string example: string
suggest: boolean
t: (key: string, params?: Record<string, string>) => string t: (key: string, params?: Record<string, string>) => string
} }
@@ -10,6 +9,5 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell") if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example }) return input.t("prompt.placeholder.normal", { example: input.example })
} }
@@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
ref={(el) => { ref={(el) => {
if (props.popover === "slash") props.setSlashPopoverRef(el) if (props.popover === "slash") props.setSlashPopoverRef(el)
}} }}
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] overflow-auto no-scrollbar flex flex-col p-2 rounded-md
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<Switch> <Switch>
@@ -12,27 +12,24 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => { const clientFor = (directory: string) => ({
createdClients.push(directory) session: {
return { create: async () => {
session: { createdSessions.push(directory)
create: async () => { return { data: { id: `session-${createdSessions.length}` } }
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
}, },
worktree: { shell: async () => {
create: async () => ({ data: { directory: `${directory}/new` } }), sentShell.push(directory)
return { data: undefined }
}, },
} prompt: async () => ({ data: undefined }),
} command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
})
beforeAll(async () => { beforeAll(async () => {
const rootClient = clientFor("/repo/main") const rootClient = clientFor("/repo/main")
@@ -91,17 +88,11 @@ beforeAll(async () => {
})) }))
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => ({
const sdk = { directory: "/repo/main",
directory: "/repo/main", client: rootClient,
client: rootClient, url: "http://localhost:4096",
url: "http://localhost:4096", }),
createClient(opts: any) {
return clientFor(opts.directory)
},
}
return sdk
},
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
@@ -1,20 +1,21 @@
import type { Message } from "@opencode-ai/sdk/v2/client" import { Accessor } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -55,6 +56,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal() const local = useLocal()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
@@ -78,7 +80,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
queued.abort.abort() queued.abort.abort()
queued.cleanup() queued.cleanup()
pending.delete(sessionID) pending.delete(sessionID)
globalSync.todo.set(sessionID, undefined)
return Promise.resolve() return Promise.resolve()
} }
return sdk.client.session return sdk.client.session
@@ -86,9 +87,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionID, sessionID,
}) })
.catch(() => {}) .catch(() => {})
.finally(() => {
globalSync.todo.set(sessionID, undefined)
})
} }
const restoreCommentItems = (items: CommentItem[]) => { const restoreCommentItems = (items: CommentItem[]) => {
@@ -173,7 +171,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = sdk.createClient({ client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
@@ -368,10 +368,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined } const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => { timer.id = window.setTimeout(() => {
resolve({ resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs) }, timeoutMs)
}) })
+174 -312
View File
@@ -1,7 +1,6 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { For, Show, createMemo, type Component } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
@@ -13,98 +12,25 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const language = useLanguage() const language = useLanguage()
const questions = createMemo(() => props.request.questions) const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
tab: 0, tab: 0,
answers: [] as QuestionAnswer[], answers: [] as QuestionAnswer[],
custom: [] as string[], custom: [] as string[],
customOn: [] as boolean[],
editing: false, editing: false,
sending: false, sending: false,
}) })
let root: HTMLDivElement | undefined
const question = createMemo(() => questions()[store.tab]) const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? []) const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "") const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true) const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const summary = createMemo(() => { const value = input()
const n = Math.min(store.tab + 1, total()) if (!value) return false
return `${n} of ${total()} questions` return store.answers[store.tab]?.includes(value) ?? false
})
const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
setStore("custom", store.tab, value)
if (!selected) return
if (multi()) {
setStore("answers", store.tab, (current = []) => {
const removed = prev ? current.filter((item) => item.trim() !== prev) : current
if (!next) return removed
if (removed.some((item) => item.trim() === next)) return removed
return [...removed, next]
})
return
}
setStore("answers", store.tab, next ? [next] : [])
}
const measure = () => {
if (!root) return
const scroller = document.querySelector(".session-scroller")
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
if (!top) {
root.style.removeProperty("--question-prompt-max-height")
return
}
const dock = root.closest('[data-component="session-prompt-dock"]')
if (!(dock instanceof HTMLElement)) return
const dockBottom = dock.getBoundingClientRect().bottom
const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
const gap = 8
const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
root.style.setProperty("--question-prompt-max-height", `${max}px`)
}
onMount(() => {
let raf: number | undefined
const update = () => {
if (raf !== undefined) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
raf = undefined
measure()
})
}
update()
window.addEventListener("resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".session-scroller")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf)
})
}) })
const fail = (err: unknown) => { const fail = (err: unknown) => {
@@ -138,13 +64,23 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
} }
} }
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const submit = () => {
void reply(questions().map((_, i) => store.answers[i] ?? []))
}
const pick = (answer: string, custom: boolean = false) => { const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer]) setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer)
if (!custom) setStore("customOn", store.tab, false) if (custom) {
setStore("editing", false) setStore("custom", store.tab, answer)
}
if (single()) {
void reply([[answer]])
return
}
setStore("tab", store.tab + 1)
} }
const toggle = (answer: string) => { const toggle = (answer: string) => {
@@ -154,41 +90,16 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}) })
} }
const customToggle = () => { const selectTab = (index: number) => {
if (store.sending) return setStore("tab", index)
if (!multi()) {
setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
return
}
const next = !on()
setStore("customOn", store.tab, next)
if (next) {
setStore("editing", true)
customUpdate(input(), true)
return
}
const value = input().trim()
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false) setStore("editing", false)
} }
const customOpen = () => {
if (store.sending) return
if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
}
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (store.sending) return if (store.sending) return
if (optIndex === options().length) { if (optIndex === options().length) {
customOpen() setStore("editing", true)
return return
} }
@@ -201,225 +112,176 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
pick(opt.label) pick(opt.label)
} }
const commitCustom = () => { const handleCustomSubmit = (e: Event) => {
setStore("editing", false) e.preventDefault()
customUpdate(input())
}
const next = () => {
if (store.sending) return if (store.sending) return
if (store.editing) commitCustom()
if (store.tab >= total() - 1) { const value = input().trim()
submit() if (!value) {
setStore("editing", false)
return return
} }
setStore("tab", store.tab + 1) if (multi()) {
setStore("editing", false) setStore("answers", store.tab, (current = []) => {
} if (current.includes(value)) return current
return [...current, value]
})
setStore("editing", false)
return
}
const back = () => { pick(value, true)
if (store.sending) return
if (store.tab <= 0) return
setStore("tab", store.tab - 1)
setStore("editing", false)
}
const jump = (tab: number) => {
if (store.sending) return
setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
} }
return ( return (
<DockPrompt <div data-component="question-prompt">
kind="question" <Show when={!single()}>
ref={(el) => (root = el)} <div data-slot="question-tabs">
header={ <For each={questions()}>
<> {(q, index) => {
<div data-slot="question-header-title">{summary()}</div> const active = () => index() === store.tab
<div data-slot="question-progress"> const answered = () => (store.answers[index()]?.length ?? 0) > 0
<For each={questions()}> return (
{(_, i) => (
<button <button
type="button" data-slot="question-tab"
data-slot="question-progress-segment" data-active={active()}
data-active={i() === store.tab} data-answered={answered()}
data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending} disabled={store.sending}
onClick={() => jump(i())} onClick={() => selectTab(index())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} >
/> {q.header}
)} </button>
</For> )
</div> }}
</> </For>
} <button
footer={ data-slot="question-tab"
<> data-active={confirm()}
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}> disabled={store.sending}
{language.t("ui.common.dismiss")} onClick={() => selectTab(questions().length)}
</Button> >
<div data-slot="question-footer-actions"> {language.t("ui.common.confirm")}
<Show when={store.tab > 0}> </button>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}> </div>
{language.t("ui.common.back")}
</Button>
</Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show> </Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show <Show when={!confirm()}>
when={store.editing} <div data-slot="question-content">
fallback={ <div data-slot="question-text">
{question()?.question}
{multi() ? " " + language.t("ui.question.multiHint") : ""}
</div>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<Show when={picked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
)
}}
</For>
<button <button
data-slot="question-option" data-slot="question-option"
data-custom="true" data-picked={customPicked()}
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending} disabled={store.sending}
onClick={customOpen} onClick={() => selectOption(options().length)}
> >
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea <Show when={!store.editing && input()}>
ref={(el) => <span data-slot="option-description">{input()}</span>
setTimeout(() => { </Show>
el.focus() <Show when={customPicked()}>
el.style.height = "0px" <Icon name="check-small" size="normal" />
el.style.height = `${el.scrollHeight}px` </Show>
}, 0) </button>
} <Show when={store.editing}>
data-slot="question-custom-input" <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
placeholder={language.t("ui.question.custom.placeholder")} <input
value={input()} ref={(el) => setTimeout(() => el.focus(), 0)}
rows={1} type="text"
disabled={store.sending} data-slot="custom-input"
onKeyDown={(e) => { placeholder={language.t("ui.question.custom.placeholder")}
if (e.key === "Escape") { value={input()}
e.preventDefault() disabled={store.sending}
setStore("editing", false) onInput={(e) => {
return setStore("custom", store.tab, e.currentTarget.value)
} }}
if (e.key !== "Enter" || e.shiftKey) return />
e.preventDefault() <Button type="submit" variant="primary" size="small" disabled={store.sending}>
commitCustom() {multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
}} </Button>
onInput={(e) => { <Button
customUpdate(e.currentTarget.value) type="button"
e.currentTarget.style.height = "0px" variant="ghost"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` size="small"
}} disabled={store.sending}
/> onClick={() => setStore("editing", false)}
</span> >
</form> {language.t("ui.common.cancel")}
</Button>
</form>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}}
</For>
</div>
</Show>
<div data-slot="question-actions">
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
{language.t("ui.common.dismiss")}
</Button>
<Show when={!single()}>
<Show when={confirm()}>
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
{language.t("ui.common.submit")}
</Button>
</Show>
<Show when={!confirm() && multi()}>
<Button
variant="secondary"
size="small"
onClick={() => selectTab(store.tab + 1)}
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
>
{language.t("ui.common.next")}
</Button>
</Show>
</Show> </Show>
</div> </div>
</DockPrompt> </div>
) )
} }
@@ -1,19 +1,10 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
createEffect, import { serverDisplayName } from "@/context/server"
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps { interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any url: string
status?: ServerHealth status?: ServerHealth
class?: string class?: string
nameClass?: string nameClass?: string
@@ -26,7 +17,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.conn)) const name = createMemo(() => serverDisplayName(props.url))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -36,7 +27,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => { createEffect(() => {
name() name()
props.conn.http.url props.url
props.status?.version props.status?.version
queueMicrotask(check) queueMicrotask(check)
}) })
@@ -1,5 +1,5 @@
import { Match, Show, Switch, createMemo } from "solid-js" import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@@ -11,7 +11,6 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
placement?: TooltipProps["placement"]
} }
function openSessionContext(args: { function openSessionContext(args: {
@@ -53,11 +52,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
if (tabs().active() === "context") {
tabs().close("context")
return
}
openSessionContext({ openSessionContext({
view: view(), view: view(),
layout, layout,
@@ -96,7 +90,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return ( return (
<Show when={params.id}> <Show when={params.id}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}> <Tooltip value={tooltipValue()} placement="top">
<Switch> <Switch>
<Match when={variant() === "indicator"}>{circle()}</Match> <Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={true}> <Match when={true}>
@@ -1,208 +0,0 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
class="block"
>
<circle
cx="6"
cy="6"
r="3"
style={{
animation: "var(--animate-pulse-scale)",
"transform-origin": "center",
"transform-box": "fill-box",
}}
/>
</svg>
)
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const summary = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
})
const active = createMemo(
() =>
props.todos.find((todo) => todo.status === "in_progress") ??
props.todos.find((todo) => todo.status === "pending") ??
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
props.todos[0],
)
const preview = createMemo(() => active()?.content ?? "")
return (
<div
classList={{
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
"h-[78px]": store.collapsed,
}}
>
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<Show when={store.collapsed}>
<div class="ml-1 flex-1 min-w-0">
<Show when={preview()}>
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
</Show>
</div>
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": !store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
)
}
function TodoList(props: { todos: Todo[]; open: boolean }) {
const [stuck, setStuck] = createSignal(false)
const [scrolling, setScrolling] = createSignal(false)
let scrollRef!: HTMLDivElement
let timer: number | undefined
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
const ensure = () => {
if (!props.open) return
if (scrolling()) return
if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const container = scrollRef.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - container.top + scrollRef.scrollTop
const bottom = rect.bottom - container.top + scrollRef.scrollTop
const viewTop = scrollRef.scrollTop + topFade
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
if (top < viewTop) {
scrollRef.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
}
setStuck(scrollRef.scrollTop > 0)
}
createEffect(
on([() => props.open, inProgress], () => {
if (!props.open || inProgress() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
return (
<div class="relative">
<div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
ref={scrollRef}
style={{ "overflow-anchor": "none" }}
onScroll={(e) => {
setStuck(e.currentTarget.scrollTop > 0)
setScrolling(true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setScrolling(false)
if (inProgress() < 0) return
requestAnimationFrame(ensure)
}, 250)
}}
>
<For each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo.status === "completed"}
indeterminate={todo.status === "in_progress"}
data-in-progress={todo.status === "in_progress" ? "" : undefined}
icon={dot(todo.status)}
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
>
<span
class="text-14-regular min-w-0 break-words"
classList={{
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
}}
style={{
"line-height": "var(--line-height-normal)",
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</span>
</Checkbox>
)}
</For>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
style={{
background: "linear-gradient(to bottom, var(--background-base), transparent)",
opacity: stuck() ? 1 : 0,
}}
/>
</div>
)
}
@@ -5,7 +5,6 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -17,6 +16,13 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = { const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)", system: "var(--syntax-info)",
user: "var(--syntax-success)", user: "var(--syntax-success)",
@@ -85,45 +91,11 @@ function RawMessage(props: {
) )
} }
const emptyMessages: Message[] = [] export function SessionContextTab(props: SessionContextTabProps) {
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
},
emptyMessages,
{ equals: same },
)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.locale(), { new Intl.NumberFormat(language.locale(), {
@@ -132,7 +104,7 @@ export function SessionContextTab() {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale())) const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -141,7 +113,7 @@ export function SessionContextTab() {
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
const all = messages() const all = props.messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return { return {
@@ -152,7 +124,7 @@ export function SessionContextTab() {
}) })
const systemPrompt = createMemo(() => { const systemPrompt = createMemo(() => {
const msg = findLast(visibleUserMessages(), (m) => !!m.system) const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
const system = msg?.system const system = msg?.system
if (!system) return if (!system) return
const trimmed = system.trim() const trimmed = system.trim()
@@ -174,12 +146,12 @@ export function SessionContextTab() {
const breakdown = createMemo( const breakdown = createMemo(
on( on(
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => { () => {
const c = ctx() const c = ctx()
if (!c?.input) return [] if (!c?.input) return []
return estimateSessionContextBreakdown({ return estimateSessionContextBreakdown({
messages: messages(), messages: props.messages(),
parts: sync.data.part as Record<string, Part[] | undefined>, parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input, input: c.input,
systemPrompt: systemPrompt(), systemPrompt: systemPrompt(),
@@ -197,7 +169,7 @@ export function SessionContextTab() {
} }
const stats = [ const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
@@ -214,7 +186,7 @@ export function SessionContextTab() {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost }, { label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, { label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[] ] satisfies { label: string; value: () => JSX.Element }[]
@@ -227,7 +199,7 @@ export function SessionContextTab() {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = view().scroll("context") const s = props.view()?.scroll("context")
if (!s) return if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -248,13 +220,13 @@ export function SessionContextTab() {
pending = undefined pending = undefined
if (!next) return if (!next) return
view().setScroll("context", next) props.view().setScroll("context", next)
}) })
} }
createEffect( createEffect(
on( on(
() => messages().length, () => props.messages().length,
() => { () => {
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
}, },
@@ -328,7 +300,7 @@ export function SessionContextTab() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={props.messages()}>
{(message) => ( {(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)} )}
@@ -257,12 +257,27 @@ export function SessionHeader() {
] as const ] as const
}) })
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
const list = apps()
return list.every((app) => exists[app.id] !== undefined)
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false }) const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
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")
})
const openDir = (app: OpenApp) => { const openDir = (app: OpenApp) => {
const directory = projectDirectory() const directory = projectDirectory()
if (!directory) return if (!directory) return
@@ -296,25 +311,25 @@ export function SessionHeader() {
platform, platform,
}) })
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const leftMount = createMemo(
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
)
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return ( return (
<> <>
<Show when={centerMount()}> <Show when={leftMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<Button <button
type="button" type="button"
variant="ghost" 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"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")} onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")} aria-label={language.t("session.header.searchFiles")}
> >
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible"> <div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible">
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" /> <Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" />
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left"> <span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center">
{language.t("session.header.search.placeholder", { project: name() })} {language.t("session.header.search.placeholder", { project: name() })}
</span> </span>
</div> </div>
@@ -324,24 +339,24 @@ export function SessionHeader() {
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind> <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
)} )}
</Show> </Show>
</Button> </button>
</Portal> </Portal>
)} )}
</Show> </Show>
<Show when={rightMount()}> <Show when={rightMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<StatusPopover /> <StatusPopover />
<Show when={projectDirectory()}> <Show when={projectDirectory()}>
<div class="hidden xl:flex items-center"> <div class="hidden xl:flex items-center">
<Show <Show
when={canOpen()} when={canOpen()}
fallback={ fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none" class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath} onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")} aria-label={language.t("session.header.open.copyPath")}
> >
@@ -354,10 +369,10 @@ export function SessionHeader() {
} }
> >
<div class="flex items-center"> <div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none" class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
onClick={() => openDir(current().id)} onClick={() => openDir(current().id)}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
> >
@@ -366,9 +381,9 @@ export function SessionHeader() {
</div> </div>
<span class="text-12-regular text-text-strong">Open</span> <span class="text-12-regular text-text-strong">Open</span>
</Button> </Button>
<div class="self-stretch w-px bg-border-weak-base" /> <div class="self-stretch w-px bg-border-base/70" />
<DropdownMenu <DropdownMenu
gutter={4} gutter={6}
placement="bottom-end" placement="bottom-end"
open={menu.open} open={menu.open}
onOpenChange={(open) => setMenu("open", open)} onOpenChange={(open) => setMenu("open", open)}
@@ -377,7 +392,7 @@ export function SessionHeader() {
as={IconButton} as={IconButton}
icon="chevron-down" icon="chevron-down"
variant="ghost" variant="ghost"
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover" 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")} aria-label={language.t("session.header.open.menu")}
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
@@ -385,7 +400,7 @@ export function SessionHeader() {
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel> <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup <DropdownMenu.RadioGroup
value={current().id} value={prefs.app}
onChange={(value) => { onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp) setPrefs("app", value as OpenApp)
@@ -443,7 +458,7 @@ export function SessionHeader() {
? language.t("session.share.popover.description.shared") ? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared") : language.t("session.share.popover.description.unshared")
} }
gutter={4} gutter={6}
placement="bottom-end" placement="bottom-end"
shift={-64} shift={-64}
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden" class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
@@ -451,11 +466,11 @@ export function SessionHeader() {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active", "rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined }, classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>} trigger={language.t("session.share.action.share")}
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Show <Show
@@ -524,7 +539,7 @@ export function SessionHeader() {
<IconButton <IconButton
icon={share.state.copied ? "check" : "link"} icon={share.state.copied ? "check" : "link"}
variant="ghost" variant="ghost"
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none" class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))} onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare} disabled={share.state.unshare}
aria-label={ aria-label={
@@ -537,97 +552,94 @@ export function SessionHeader() {
</Show> </Show>
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1"> <div class="flex items-center gap-3 ml-2 shrink-0">
<div class="hidden md:flex items-center gap-1 shrink-0"> <TooltipKeybind
<TooltipKeybind title={language.t("command.terminal.toggle")}
title={language.t("command.terminal.toggle")} keybind={command.keybind("terminal.toggle")}
keybind={command.keybind("terminal.toggle")} >
<Button
variant="ghost"
class="group/terminal-toggle size-6 p-0"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
> >
<Button <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
variant="ghost" <Icon
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border" size="small"
onClick={() => view().terminal.toggle()} name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
aria-label={language.t("command.terminal.toggle")} class="group-hover/terminal-toggle:hidden"
aria-expanded={view().terminal.opened()} />
aria-controls="terminal-panel" <Icon
> size="small"
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> name="layout-bottom-partial"
<Icon class="hidden group-hover/terminal-toggle:inline-block"
size="small" />
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"} <Icon
class="group-hover/terminal-toggle:hidden" size="small"
/> name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
<Icon class="hidden group-active/terminal-toggle:inline-block"
size="small" />
name="layout-bottom-partial" </div>
class="hidden group-hover/terminal-toggle:inline-block" </Button>
/> </TooltipKeybind>
<Icon </div>
size="small" <div class="hidden lg:block shrink-0">
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"} <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
class="hidden group-active/terminal-toggle:inline-block" <Button
/> variant="ghost"
</div> class="group/review-toggle size-6 p-0"
</Button> onClick={() => view().reviewPanel.toggle()}
</TooltipKeybind> aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
<TooltipKeybind aria-controls="review-panel"
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
> >
<Button <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
variant="ghost" <Icon
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border" size="small"
onClick={() => view().reviewPanel.toggle()} name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
aria-label={language.t("command.review.toggle")} class="group-hover/review-toggle:hidden"
aria-expanded={view().reviewPanel.opened()} />
aria-controls="review-panel" <Icon
> size="small"
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> name="layout-right-partial"
<Icon class="hidden group-hover/review-toggle:inline-block"
size="small" />
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"} <Icon
class="group-hover/review-toggle:hidden" size="small"
/> name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
<Icon class="hidden group-active/review-toggle:inline-block"
size="small" />
name="layout-right-partial" </div>
class="hidden group-hover/review-toggle:inline-block" </Button>
/> </TooltipKeybind>
<Icon </div>
size="small" <div class="hidden lg:block shrink-0">
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"} <TooltipKeybind
class="hidden group-active/review-toggle:inline-block" title={language.t("command.fileTree.toggle")}
/> keybind={command.keybind("fileTree.toggle")}
</div> >
</Button> <Button
</TooltipKeybind> variant="ghost"
class="group/file-tree-toggle size-6 p-0"
<TooltipKeybind onClick={() => layout.fileTree.toggle()}
title={language.t("command.fileTree.toggle")} aria-label={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")} aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
> >
<Button <div class="relative flex items-center justify-center size-4">
variant="ghost" <Icon
class="titlebar-icon w-8 h-6 p-0 box-border" size="small"
onClick={() => layout.fileTree.toggle()} name="bullet-list"
aria-label={language.t("command.fileTree.toggle")} classList={{
aria-expanded={layout.fileTree.opened()} "text-icon-strong": layout.fileTree.opened(),
aria-controls="file-tree-panel" "text-icon-weak": !layout.fileTree.opened(),
> }}
<div class="relative flex items-center justify-center size-4"> />
<Icon </div>
size="small" </Button>
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"} </TooltipKeybind>
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div> </div>
</div> </div>
</Portal> </Portal>
@@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main" const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create" const CREATE_WORKTREE = "create"
const ROOT_CLASS = const ROOT_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-16" "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)]"
interface NewSessionViewProps { interface NewSessionViewProps {
worktree: string worktree: string
@@ -128,7 +128,6 @@ export const SettingsGeneral: Component = () => {
{ value: "roboto-mono", label: "font.option.robotoMono" }, { value: "roboto-mono", label: "font.option.robotoMono" },
{ value: "source-code-pro", label: "font.option.sourceCodePro" }, { value: "source-code-pro", label: "font.option.sourceCodePro" },
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" }, { value: "ubuntu-mono", label: "font.option.ubuntuMono" },
{ value: "geist-mono", label: "font.option.geistMono" },
] as const ] as const
const fontOptionsList = [...fontOptions] const fontOptionsList = [...fontOptions]
@@ -431,7 +430,7 @@ export const SettingsGeneral: Component = () => {
<SoundsSection /> <SoundsSection />
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}> <Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => { {(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.()) const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest) const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
@@ -457,7 +456,7 @@ export const SettingsGeneral: Component = () => {
</div> </div>
) )
}} }}
</Show>*/} </Show>
<UpdatesSection /> <UpdatesSection />
+50 -60
View File
@@ -1,21 +1,21 @@
import { Button } from "@opencode-ai/ui/button" import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row" import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/context/platform" import { Popover } from "@opencode-ai/ui/popover"
import { useSDK } from "@/context/sdk" import { Tabs } from "@opencode-ai/ui/tabs"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server" import { DialogSelectServer } from "./dialog-select-server"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000 const pollMs = 10_000
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
} }
const listServersByHealth = ( const listServersByHealth = (
list: ServerConnection.Any[], list: string[],
active: ServerConnection.Key | undefined, active: string | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>, status: Record<string, ServerHealth | undefined>,
) => { ) => {
if (!list.length) return list if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
} }
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1 if (a === active) return -1
if (ServerConnection.key(b) === active) return 1 if (b === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)]) const diff = rank(status[a]) - rank(status[b])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
} }
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => { const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
const list = servers() const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
const refresh = async () => { const refresh = async () => {
const results: Record<string, ServerHealth> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
list.map(async (conn) => { list.map(async (url) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) results[url] = await checkServerHealth(url, fetcher)
}), }),
) )
if (dead) return if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
return status return status
} }
const useDefaultServerKey = ( const useDefaultServerUrl = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
const [url, setUrl] = createSignal<string | undefined>() const [url, setUrl] = createSignal<string | undefined>()
@@ -117,14 +117,7 @@ const useDefaultServerKey = (
}) })
}) })
return { return { url, refresh: () => setTick((value) => value + 1) }
key: () => {
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
}
} }
const useMcpToggle = (input: { const useMcpToggle = (input: {
@@ -170,16 +163,16 @@ export function StatusPopover() {
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.current const current = server.url
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] return [current, ...list.filter((item) => item !== current)]
}) })
const health = useServerHealth(servers, fetcher) const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const mcp = useMcpToggle({ sync, sdk, language }) const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl) const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -203,26 +196,24 @@ export function StatusPopover() {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active", "rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={ trigger={
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-1.5">
<div class="size-4 flex items-center justify-center"> <div
<div classList={{
classList={{ "size-1.5 rounded-full": true,
"size-1.5 rounded-full": true, "bg-icon-success-base": overallHealthy(),
"bg-icon-success-base": overallHealthy(), "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined, "bg-border-weak-base": server.healthy() === undefined,
"bg-border-weak-base": server.healthy() === undefined, }}
}} />
/>
</div>
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span> <span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
</div> </div>
} }
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl" class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={4} gutter={6}
placement="bottom-end" placement="bottom-end"
shift={-136} shift={-136}
> >
@@ -258,9 +249,8 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(s) => { {(url) => {
const key = ServerConnection.key(s) const isBlocked = () => health[url]?.healthy === false
const isBlocked = () => health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -272,19 +262,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(key) server.setActive(url)
navigate("/") navigate("/")
}} }}
> >
<ServerRow <ServerRow
conn={s} url={url}
status={health[key]} status={health[url]}
dimmed={isBlocked()} dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
badge={ badge={
<Show when={key === defaultServer.key()}> <Show when={url === defaultServer.url()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")} {language.t("common.default")}
</span> </span>
@@ -292,7 +282,7 @@ export function StatusPopover() {
} }
> >
<div class="flex-1" /> <div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}> <Show when={url === server.url}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" /> <Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show> </Show>
</ServerRow> </ServerRow>
+19 -41
View File
@@ -1,15 +1,14 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { showToast } from "@opencode-ai/ui/toast" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings" import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import { parseKeybind, matchKeybind } from "@/context/command"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
@@ -107,14 +106,8 @@ const useTerminalUiBindings = (input: {
input.container.addEventListener("pointerdown", input.handlePointerDown) input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { input.container.addEventListener("click", input.handleLinkClick, { capture: true })
capture: true, input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
})
input.cleanups.push(() =>
input.container.removeEventListener("click", input.handleLinkClick, {
capture: true,
}),
)
input.term.textarea?.addEventListener("focus", handleTextareaFocus) input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur) input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -155,7 +148,6 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
let ws: WebSocket | undefined let ws: WebSocket | undefined
@@ -354,7 +346,7 @@ export const Terminal = (props: TerminalProps) => {
} }
ghostty = g ghostty = g
term = t term = t
output = terminalWriter((data, done) => t.write(data, done)) output = terminalWriter((data) => t.write(data))
t.attachCustomKeyEventHandler((event) => { t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
@@ -380,13 +372,7 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer serializeAddon = serializer
t.open(container) t.open(container)
useTerminalUiBindings({ useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
focusTerminal() focusTerminal()
@@ -442,8 +428,10 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory) url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "" if (window.__OPENCODE__?.serverPassword) {
url.password = server.current?.http.password ?? "" url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url) const socket = new WebSocket(url)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket
@@ -532,19 +520,9 @@ export const Terminal = (props: TerminalProps) => {
disposed = true disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer) if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close() output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
const finalize = () => { cleanup()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
}
if (!output) {
finalize()
return
}
output.flush(finalize)
}) })
return ( return (
+31 -55
View File
@@ -1,6 +1,6 @@
import { createEffect, createMemo, Show, untrack } from "solid-js" import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -43,7 +43,6 @@ export function Titlebar() {
const theme = useTheme() const theme = useTheme()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const params = useParams()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -172,10 +171,9 @@ export function Titlebar() {
<IconButton <IconButton
icon="menu" icon="menu"
variant="ghost" variant="ghost"
class="titlebar-icon rounded-md" class="size-8 rounded-md"
onClick={layout.mobileSidebar.toggle} onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")} aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/> />
</div> </div>
</Show> </Show>
@@ -184,14 +182,13 @@ export function Titlebar() {
<IconButton <IconButton
icon="menu" icon="menu"
variant="ghost" variant="ghost"
class="titlebar-icon rounded-md" class="size-8 rounded-md"
onClick={layout.mobileSidebar.toggle} onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")} aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/> />
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-3 shrink-0">
<TooltipKeybind <TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"} class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom" placement="bottom"
@@ -200,7 +197,7 @@ export function Titlebar() {
> >
<Button <Button
variant="ghost" variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border" class="group/sidebar-toggle size-6 p-0"
onClick={layout.sidebar.toggle} onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")} aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()} aria-expanded={layout.sidebar.opened()}
@@ -208,77 +205,56 @@ export function Titlebar() {
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon <Icon
size="small" size="small"
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"} name={layout.sidebar.opened() ? "layout-left-full" : "layout-left"}
class="group-hover/sidebar-toggle:hidden" class="group-hover/sidebar-toggle:hidden"
/> />
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" /> <Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
<Icon <Icon
size="small" size="small"
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"} name={layout.sidebar.opened() ? "layout-left" : "layout-left-full"}
class="hidden group-active/sidebar-toggle:inline-block" class="hidden group-active/sidebar-toggle:inline-block"
/> />
</div> </div>
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0"> <div class="hidden xl:flex items-center gap-1 shrink-0">
<Show when={params.dir}> <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<TooltipKeybind <Button
placement="bottom" variant="ghost"
title={language.t("command.session.new")} icon="arrow-left"
keybind={command.keybind("session.new")} class="size-6 p-0"
openDelay={2000} disabled={!canBack()}
> onClick={back}
<Button aria-label={language.t("common.goBack")}
variant="ghost" />
icon="new-session" </Tooltip>
class="titlebar-icon w-8 h-6 p-0 box-border" <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
onClick={() => { <Button
if (!params.dir) return variant="ghost"
navigate(`/${params.dir}/session`) icon="arrow-right"
}} class="size-6 p-0"
aria-label={language.t("command.session.new")} disabled={!canForward()}
/> onClick={forward}
</TooltipKeybind> aria-label={language.t("common.goForward")}
</Show> />
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}> </Tooltip>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</div> </div>
</div> </div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div> </div>
<div class="min-w-0 flex items-center justify-center pointer-events-none"> <div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" /> <div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
</div> </div>
<div <div
classList={{ classList={{
"flex items-center min-w-0 justify-end": true, "flex items-center min-w-0 justify-end": true,
"pr-2": !windows(), "pr-6": !windows(),
}} }}
onMouseDown={drag} onMouseDown={drag}
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
<Show when={windows()}> <Show when={windows()}>
<div class="w-6 shrink-0" /> <div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />
+2 -4
View File
@@ -11,7 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested." const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
function actionId(id: string) { function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id if (!id.startsWith(SUGGESTED_PREFIX)) return id
@@ -316,10 +316,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const isPalette = palette().has(sig) const isPalette = palette().has(sig)
const option = keymap().get(sig) const option = keymap().get(sig)
const modified = event.ctrlKey || event.metaKey || event.altKey const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab) if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
return
if (isPalette) { if (isPalette) {
event.preventDefault() event.preventDefault()
+22 -78
View File
@@ -1,16 +1,10 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
const abortError = z.object({
name: z.literal("AbortError"),
})
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK", name: "GlobalSDK",
init: () => { init: () => {
@@ -18,10 +12,20 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => { const eventFetch = (() => {
if (!platform.fetch || !server.current) return if (!platform.fetch) return
try { try {
const url = new URL(server.current.http.url) const url = new URL(server.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch if (url.protocol === "http:" && !loopback) return platform.fetch
} catch { } catch {
@@ -29,13 +33,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
} }
})() })()
const currentServer = server.current const eventSdk = createOpencodeClient({
if (!currentServer) throw new Error("No server available") baseUrl: server.url,
const eventSdk = createSdkForServer({
signal: abort.signal, signal: abort.signal,
fetch: eventFetch, fetch: eventFetch,
server: currentServer.http, headers: eventFetch ? undefined : auth,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: Event
@@ -91,51 +93,23 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
let streamErrorLogged = false let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
void (async () => { void (async () => {
while (!abort.signal.aborted) { while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try { try {
const events = await eventSdk.global.event({ const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => { onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return if (streamErrorLogged) return
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream error", { console.error("[global-sdk] event stream error", {
url: currentServer.http.url, url: server.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
}, },
}) })
let yielded = Date.now() let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) { for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
const directory = event.directory ?? "global" const directory = event.directory ?? "global"
const payload = event.payload const payload = event.payload
@@ -156,18 +130,14 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
await wait(0) await wait(0)
} }
} catch (error) { } catch (error) {
if (!aborted(error) && !streamErrorLogged) { if (!streamErrorLogged) {
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream failed", { console.error("[global-sdk] event stream failed", {
url: currentServer.http.url, url: server.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
} }
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
} }
if (abort.signal.aborted) return if (abort.signal.aborted) return
@@ -175,43 +145,17 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
} }
})().finally(flush) })().finally(flush)
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
onCleanup(() => { onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
abort.abort() abort.abort()
flush() flush()
}) })
const sdk = createSdkForServer({ const sdk = createOpencodeClient({
server: server.current.http, baseUrl: server.url,
fetch: platform.fetch, fetch: platform.fetch,
throwOnError: true, throwOnError: true,
}) })
return { return { url: server.url, client: sdk, event: emitter }
url: currentServer.http.url,
client: sdk,
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
}, },
}) })
@@ -30,6 +30,7 @@ describe("pickDirectoriesToEvict", () => {
describe("loadRootSessionsWithFallback", () => { describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => { test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = [] const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({ const result = await loadRootSessionsWithFallback({
directory: "dir", directory: "dir",
@@ -38,15 +39,20 @@ describe("loadRootSessionsWithFallback", () => {
calls.push(query) calls.push(query)
return { data: [] } return { data: [] }
}, },
onFallback: () => {
fallback += 1
},
}) })
expect(result.data).toEqual([]) expect(result.data).toEqual([])
expect(result.limited).toBe(true) expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
expect(fallback).toBe(0)
}) })
test("falls back to full roots query on limited-query failure", async () => { test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = [] const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({ const result = await loadRootSessionsWithFallback({
directory: "dir", directory: "dir",
@@ -56,6 +62,9 @@ describe("loadRootSessionsWithFallback", () => {
if (query.limit) throw new Error("unsupported") if (query.limit) throw new Error("unsupported")
return { data: [] } return { data: [] }
}, },
onFallback: () => {
fallback += 1
},
}) })
expect(result.data).toEqual([]) expect(result.data).toEqual([])
@@ -64,6 +73,7 @@ describe("loadRootSessionsWithFallback", () => {
{ directory: "dir", roots: true, limit: 25 }, { directory: "dir", roots: true, limit: 25 },
{ directory: "dir", roots: true }, { directory: "dir", roots: true },
]) ])
expect(fallback).toBe(1)
}) })
}) })
+58 -65
View File
@@ -1,50 +1,46 @@
import type { import {
Config, type Config,
OpencodeClient, type Path,
Path, type Project,
Project, type ProviderAuthResponse,
ProviderAuthResponse, type ProviderListResponse,
ProviderListResponse, createOpencodeClient,
Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { createStore, produce, reconcile } from "solid-js/store"
import { getFilename } from "@opencode-ai/util/path" import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import { import {
createContext, createContext,
createEffect, createEffect,
untrack,
getOwner, getOwner,
Match, useContext,
onCleanup, onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Switch, Switch,
untrack, Match,
useContext,
} from "solid-js" } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { createChildStoreManager } from "./global-sync/child-store"
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
error?: InitError error?: InitError
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@@ -57,6 +53,14 @@ function errorMessage(error: unknown) {
return "Unknown error" return "Unknown error"
} }
function setDevStats(value: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}) {
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
}
function createGlobalSync() { function createGlobalSync() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform() const platform = usePlatform()
@@ -64,7 +68,12 @@ function createGlobalSync() {
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner") if (!owner) throw new Error("GlobalSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>() const stats = {
evictions: 0,
loadSessionsFallback: 0,
}
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
@@ -78,25 +87,19 @@ function createGlobalSync() {
ready: false, ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value, project: projectCache.value,
session_todo: {},
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
provider_auth: {}, provider_auth: {},
config: {}, config: {},
reload: undefined, reload: undefined,
}) })
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { const updateStats = (activeDirectoryStores: number) => {
if (!sessionID) return if (!import.meta.env.DEV) return
if (!todos) { setDevStats({
setGlobalStore( activeDirectoryStores,
"session_todo", evictions: stats.evictions,
produce((draft) => { loadSessionsFullFetchFallback: stats.loadSessionsFallback,
delete draft[sessionID] })
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
} }
const paused = () => untrack(() => globalStore.reload) !== undefined const paused = () => untrack(() => globalStore.reload) !== undefined
@@ -109,6 +112,11 @@ function createGlobalSync() {
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
markStats: updateStats,
incrementEvictions: () => {
stats.evictions += 1
updateStats(Object.keys(children.children).length)
},
isBooting: (directory) => booting.has(directory), isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => { onBootstrap: (directory) => {
@@ -124,7 +132,9 @@ function createGlobalSync() {
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory) const cached = sdkCache.get(directory)
if (cached) return cached if (cached) return cached
const sdk = globalSDK.createClient({ const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory, directory,
throwOnError: true, throwOnError: true,
}) })
@@ -164,10 +174,7 @@ function createGlobalSync() {
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory) const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) { if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
limit: store.limit,
permission: store.permission,
})
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
} }
@@ -180,6 +187,10 @@ function createGlobalSync() {
directory, directory,
limit, limit,
list: (query) => globalSDK.client.session.list(query), list: (query) => globalSDK.client.session.list(query),
onFallback: () => {
stats.loadSessionsFallback += 1
updateStats(Object.keys(children.children).length)
},
}) })
.then((x) => { .then((x) => {
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
@@ -188,17 +199,10 @@ function createGlobalSync() {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID) const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
limit,
permission: store.permission,
})
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
) )
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit }) sessionMeta.set(directory, { limit })
@@ -266,11 +270,6 @@ function createGlobalSync() {
setGlobalStore("project", next) setGlobalStore("project", next)
}, },
}) })
if (event.type === "server.connected" || event.type === "global.disposed") {
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
}
return return
} }
@@ -284,7 +283,6 @@ function createGlobalSync() {
store, store,
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory), vcsCache: children.vcsCache.get(directory),
loadLsp: () => { loadLsp: () => {
sdkFor(directory) sdkFor(directory)
@@ -308,9 +306,7 @@ function createGlobalSync() {
await bootstrapGlobal({ await bootstrapGlobal({
globalSDK: globalSDK.client, globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"), connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", { connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore, setGlobalStore,
}) })
@@ -357,9 +353,6 @@ function createGlobalSync() {
bootstrap, bootstrap,
updateConfig, updateConfig,
project: projectApi, project: projectApi,
todo: {
set: setSessionTodo,
},
} }
} }
@@ -1,29 +1,25 @@
import type { import {
Config, type Config,
OpencodeClient, type Path,
Path, type PermissionRequest,
PermissionRequest, type Project,
Project, type ProviderAuthResponse,
ProviderAuthResponse, type ProviderListResponse,
ProviderListResponse, type QuestionRequest,
QuestionRequest, createOpencodeClient,
Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils" import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@@ -31,7 +27,7 @@ type GlobalStore = {
} }
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient globalSDK: ReturnType<typeof createOpencodeClient>
connectErrorTitle: string connectErrorTitle: string
connectErrorDescription: string connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
@@ -110,13 +106,13 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
sdk: OpencodeClient sdk: ReturnType<typeof createOpencodeClient>
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
}) { }) {
if (input.store.status !== "complete") input.setStore("status", "loading") input.setStore("status", "loading")
const blockingRequests = { const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
@@ -17,6 +17,8 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({ const manager = createChildStoreManager({
owner, owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},
@@ -17,6 +17,8 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
markStats: (activeDirectoryStores: number) => void
incrementEvictions: () => void
isBooting: (directory: string) => boolean isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void onBootstrap: (directory: string) => void
@@ -100,6 +102,7 @@ export function createChildStoreManager(input: {
} }
delete children[directory] delete children[directory]
input.onDispose(directory) input.onDispose(directory)
input.markStats(Object.keys(children).length)
return true return true
} }
@@ -117,6 +120,7 @@ export function createChildStoreManager(input: {
if (list.length === 0) return if (list.length === 0) return
for (const directory of list) { for (const directory of list) {
if (!disposeDirectory(directory)) continue if (!disposeDirectory(directory)) continue
input.incrementEvictions()
} }
} }
@@ -196,6 +200,7 @@ export function createChildStoreManager(input: {
}) })
runWithOwner(input.owner, init) runWithOwner(input.owner, init)
input.markStats(Object.keys(children).length)
} }
mark(directory) mark(directory)
const childStore = children[directory] const childStore = children[directory]
@@ -116,20 +116,6 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1) expect(refreshCount).toBe(1)
}) })
test("handles server.connected by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
project: [],
refresh: () => {
refreshCount += 1
},
setGlobalProject() {},
})
expect(refreshCount).toBe(1)
})
}) })
describe("applyDirectoryEvent", () => { describe("applyDirectoryEvent", () => {
@@ -20,7 +20,7 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
refresh: () => void refresh: () => void
}) { }) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") { if (input.event.type === "global.disposed") {
input.refresh() input.refresh()
return return
} }
@@ -39,12 +39,7 @@ export function applyGlobalEvent(input: {
}) })
} }
function cleanupSessionCaches( function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return if (!sessionID) return
const hasAny = const hasAny =
store.message[sessionID] !== undefined || store.message[sessionID] !== undefined ||
@@ -53,7 +48,6 @@ function cleanupSessionCaches(
store.permission[sessionID] !== undefined || store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined || store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return if (!hasAny) return
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -83,7 +77,6 @@ export function applyDirectoryEvent(input: {
directory: string directory: string
loadLsp: () => void loadLsp: () => void
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) { }) {
const event = input.event const event = input.event
switch (event.type) { switch (event.type) {
@@ -117,7 +110,7 @@ export function applyDirectoryEvent(input: {
}), }),
) )
} }
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) cleanupSessionCaches(input.store, input.setStore, info.id)
if (info.parentID) break if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
@@ -143,7 +136,7 @@ export function applyDirectoryEvent(input: {
}), }),
) )
} }
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) cleanupSessionCaches(input.store, input.setStore, info.id)
if (info.parentID) break if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
@@ -156,7 +149,6 @@ export function applyDirectoryEvent(input: {
case "todo.updated": { case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] } const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break break
} }
case "session.status": { case "session.status": {
@@ -239,24 +231,6 @@ export function applyDirectoryEvent(input: {
} }
break break
} }
case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore(
"part",
props.messageID,
produce((draft) => {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
}),
)
break
}
case "vcs.branch.updated": { case "vcs.branch.updated": {
const props = event.properties as { branch: string } const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break if (input.store.vcs?.branch === props.branch) break
@@ -9,6 +9,7 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
limited: true, limited: true,
} as const } as const
} catch { } catch {
input.onFallback()
const result = await input.list({ directory: input.directory, roots: true }) const result = await input.list({ directory: input.directory, roots: true })
return { return {
data: result.data, data: result.data,
@@ -119,6 +119,7 @@ export type RootLoadArgs = {
directory: string directory: string
limit: number limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
onFallback: () => void
} }
export type RootLoadResult = { export type RootLoadResult = {
+10 -6
View File
@@ -174,10 +174,6 @@ function detectLocale(): Locale {
return "en" return "en"
} }
function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
init: () => { init: () => {
@@ -188,7 +184,15 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}), }),
) )
const locale = createMemo<Locale>(() => normalizeLocale(store.locale)) const locale = createMemo<Locale>(() =>
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
)
createEffect(() => {
const current = locale()
if (store.locale === current) return
setStore("locale", current)
})
const dict = createMemo<Dictionary>(() => DICT[locale()]) const dict = createMemo<Dictionary>(() => DICT[locale()])
@@ -209,7 +213,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label, label,
t, t,
setLocale(next: Locale) { setLocale(next: Locale) {
setStore("locale", normalizeLocale(next)) setStore("locale", next)
}, },
} }
}, },
+7 -6
View File
@@ -1,8 +1,9 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = { type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
@@ -11,11 +12,14 @@ type SDKEventMap = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { directory: Accessor<string> }) => { init: (props: { directory: Accessor<string> }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory) const directory = createMemo(props.directory)
const client = createMemo(() => const client = createMemo(() =>
globalSDK.createClient({ createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: directory(), directory: directory(),
throwOnError: true, throwOnError: true,
}), }),
@@ -41,9 +45,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get url() { get url() {
return globalSDK.url return globalSDK.url
}, },
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
} }
}, },
}) })
+80 -124
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -15,122 +15,92 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "") return withProtocol.replace(/\/+$/, "")
} }
export function serverDisplayName(conn?: ServerConnection.Any) { export function serverDisplayName(url: string) {
if (!conn) return "" if (!url) return ""
if (conn.displayName) return conn.displayName return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
} }
function projectsKey(key: ServerConnection.Key) { function projectsKey(url: string) {
if (!key) return "" if (!url) return ""
if (key === "sidecar") return "local" const host = url.replace(/^https?:\/\//, "").split(":")[0]
const host = key.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local" if (host === "localhost" || host === "127.0.0.1") return "local"
return key return url
}
export namespace ServerConnection {
type Base = { displayName?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
} }
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => { init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), Persist.global("server", ["server.v3"]),
createStore({ createStore({
list: [] as string[], list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>, lastProject: {} as Record<string, string>,
}), }),
) )
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
]
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
return [...deduped.values()]
})
const [state, setState] = createStore({ const [state, setState] = createStore({
active: props.defaultServer, active: "",
healthy: undefined as boolean | undefined, healthy: undefined as boolean | undefined,
}) })
const healthy = () => state.healthy const healthy = () => state.healthy
function startHealthPolling(conn: ServerConnection.Any) { const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setState("active", url)
})
}
function startHealthPolling(url: string) {
let alive = true let alive = true
let busy = false let busy = false
const run = () => { const run = () => {
if (busy) return if (busy) return
busy = true busy = true
void check(conn) void check(url)
.then((next) => { .then((next) => {
if (!alive) return if (!alive) return
setState("healthy", next) setState("healthy", next)
@@ -148,73 +118,59 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
} }
function setActive(input: ServerConnection.Key) { function setActive(input: string) {
if (state.active !== input) setState("active", input) const url = normalizeServerUrl(input)
if (!url) return
setState("active", url)
} }
function add(input: string) { function add(input: string) {
const url = normalizeServerUrl(input) const url = normalizeServerUrl(input)
if (!url) return if (!url) return
return batch(() => { updateServerList(url)
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
} }
function remove(key: ServerConnection.Key) { function remove(input: string) {
const list = store.list.filter((x) => x !== key) const url = normalizeServerUrl(input)
batch(() => { if (!url) return
setStore("list", list) updateServerList(url, true)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
}
})
} }
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
})
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
createEffect(() => { createEffect(() => {
const current_ = current() const url = state.active
if (!current_) return if (!url) return
setState("healthy", undefined) setState("healthy", undefined)
onCleanup(startHealthPolling(current_)) onCleanup(startHealthPolling(url))
}) })
const origin = createMemo(() => projectsKey(state.active)) const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? []) const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo( const isLocal = createMemo(() => origin() === "local")
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" && c.variant === "base"
})
return { return {
ready: isReady, ready: isReady,
healthy, healthy,
isLocal, isLocal,
get key() { get url() {
return state.active return state.active
}, },
get name() { get name() {
return serverDisplayName(current()) return serverDisplayName(state.active)
}, },
get list() { get list() {
return allServers() return store.list
},
get current() {
return current()
}, },
setActive, setActive,
add, add,
-1
View File
@@ -85,7 +85,6 @@ const monoFonts: Record<string, string> = {
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, "roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, "source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, "ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
} }
export function monoFontFamily(font: string | undefined) { export function monoFontFamily(font: string | undefined) {
+2 -15
View File
@@ -289,25 +289,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory const directory = sdk.directory
const client = sdk.client const client = sdk.client
const [store, setStore] = globalSync.child(directory) const [store, setStore] = globalSync.child(directory)
const existing = store.todo[sessionID] if (store.todo[sessionID] !== undefined) return
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () => return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => { retry(() => client.session.todo({ sessionID })).then((todo) => {
const list = todo.data ?? [] setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}), }),
) )
}, },
+2 -15
View File
@@ -1,14 +1,11 @@
// @refresh reload // @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform" import { Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click" import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -110,22 +107,12 @@ const platform: Platform = {
setDefaultServerUrl: writeDefaultServerUrl, setDefaultServerUrl: writeDefaultServerUrl,
} }
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders>
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} /> <AppInterface />
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),

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