Compare commits

..
3 Commits
Author SHA1 Message Date
Aiden Cline 4ac88762ae fix: import 2026-02-20 01:35:02 -06:00
Aiden Cline cfe2d30a26 fixes 2026-02-20 01:17:08 -06:00
Aiden Cline d2898141be tweak: adjust stats command to show failures per model 2026-02-19 22:46:54 -06:00
1404 changed files with 64854 additions and 157562 deletions
-2
View File
@@ -8,9 +8,7 @@
# - Denounce with minus prefix: -username or -platform:username. # - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle. # - Optional details after a space following the handle.
adamdotdevin adamdotdevin
-agusbasari29 AI PR slop
ariane-emory ariane-emory
edemaine
-florianleibert -florianleibert
fwang fwang
iamdavidhill iamdavidhill
+1 -16
View File
@@ -11,25 +11,10 @@ runs:
restore-keys: | restore-keys: |
${{ runner.os }}-bun- ${{ runner.os }}-bun-
- name: Get baseline download URL
id: bun-url
shell: bash
run: |
if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
Windows) OS=windows ;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with: with:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-version-file: package.json
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
-4
View File
@@ -27,11 +27,7 @@ jobs:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch - name: Sync beta branch
env: env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts run: bun script/beta.ts
-9
View File
@@ -65,15 +65,6 @@ jobs:
body: closeMessage, body: closeMessage,
}); });
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
if (isPR) { if (isPR) {
await github.rest.pulls.update({ await github.rest.pulls.update({
owner: context.repo.owner, owner: context.repo.owner,
+10 -24
View File
@@ -12,14 +12,13 @@ jobs:
if: github.actor != 'opencode-agent[bot]' if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
id-token: write
contents: write contents: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
persist-credentials: false
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -47,26 +46,15 @@ jobs:
echo "EOF" echo "EOF"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: Install OpenCode
if: steps.changes.outputs.has_changes == 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Sync locale docs with OpenCode - name: Sync locale docs with OpenCode
if: steps.changes.outputs.has_changes == 'true' if: steps.changes.outputs.has_changes == 'true'
uses: sst/opencode/github@latest
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_CONFIG_CONTENT: | with:
{ model: opencode/gpt-5.2
"permission": { agent: docs
"*": "deny", prompt: |
"read": "allow",
"edit": "allow",
"glob": "allow",
"task": "allow"
}
}
run: |
opencode run --agent docs --model opencode/gpt-5.3-codex <<'EOF'
Update localized docs to match the latest English docs changes. Update localized docs to match the latest English docs changes.
Changed English doc files: Changed English doc files:
@@ -79,12 +67,10 @@ jobs:
2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md). 2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md).
3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates. 3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates.
4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent. 4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent.
5. Use only the minimum tools needed for this task (read/glob, file edits, and translator Task). Do not use shell, web, search, or GitHub tools for translation work. 5. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
6. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update. 6. Keep locale docs structure aligned with their corresponding English pages.
7. Keep locale docs structure aligned with their corresponding English pages. 7. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
8. Do not modify English source docs in packages/web/src/content/docs/*.mdx. 8. If no locale updates are needed, make no changes.
9. If no locale updates are needed, make no changes.
EOF
- name: Commit and push locale docs updates - name: Commit and push locale docs updates
if: steps.changes.outputs.has_changes == 'true' if: steps.changes.outputs.has_changes == 'true'
+6 -6
View File
@@ -108,11 +108,11 @@ jobs:
await removeLabel('needs:title'); await removeLabel('needs:title');
// Step 2: Check for linked issue (skip for docs/refactor/feat PRs) // Step 2: Check for linked issue (skip for docs/refactor PRs)
const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title); const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
if (skipIssueCheck) { if (skipIssueCheck) {
await removeLabel('needs:issue'); await removeLabel('needs:issue');
console.log('Skipping issue check for docs/refactor/feat PR'); console.log('Skipping issue check for docs/refactor PR');
return; return;
} }
const query = ` const query = `
@@ -189,7 +189,7 @@ jobs:
const body = pr.body || ''; const body = pr.body || '';
const title = pr.title; const title = pr.title;
const isDocsRefactorOrFeat = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title); const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
const issues = []; const issues = [];
@@ -225,8 +225,8 @@ jobs:
} }
} }
// Check: issue reference (skip for docs/refactor/feat) // Check: issue reference (skip for docs/refactor)
if (!isDocsRefactorOrFeat && hasIssueSection) { if (!isDocsOrRefactor && hasIssueSection) {
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/); const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : ''; const issueContent = issueMatch ? issueMatch[1].trim() : '';
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent); const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
+5 -158
View File
@@ -41,13 +41,6 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode - name: Install OpenCode
if: inputs.bump || inputs.version if: inputs.bump || inputs.version
run: bun i -g opencode-ai run: bun i -g opencode-ai
@@ -56,16 +49,14 @@ jobs:
run: | run: |
./script/version.ts ./script/version.ts
env: env:
GH_TOKEN: ${{ steps.committer.outputs.token }} GH_TOKEN: ${{ github.token }}
OPENCODE_BUMP: ${{ inputs.bump }} OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }} OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }}
outputs: outputs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
release: ${{ steps.version.outputs.release }} release: ${{ steps.version.outputs.release }}
tag: ${{ steps.version.outputs.tag }} tag: ${{ steps.version.outputs.tag }}
repo: ${{ steps.version.outputs.repo }}
build-cli: build-cli:
needs: version needs: version
@@ -78,13 +69,6 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build - name: Build
id: build id: build
run: | run: |
@@ -92,13 +76,13 @@ jobs:
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }} GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: opencode-cli name: opencode-cli
path: packages/opencode/dist path: packages/opencode/dist
outputs: outputs:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
@@ -205,13 +189,6 @@ jobs:
if: contains(matrix.settings.host, 'ubuntu') if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version run: cargo tauri --version
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build and upload artifacts - name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
timeout-minutes: 60 timeout-minutes: 60
@@ -219,16 +196,14 @@ jobs:
projectPath: packages/desktop projectPath: packages/desktop
uploadWorkflowArtifacts: true uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }} tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true updaterJsonPreferNsis: true
releaseId: ${{ needs.version.outputs.release }} releaseId: ${{ needs.version.outputs.release }}
tagName: ${{ needs.version.outputs.tag }} tagName: ${{ needs.version.outputs.tag }}
releaseDraft: true releaseDraft: true
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
releaseCommitish: ${{ github.sha }}
env: env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -239,131 +214,11 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
build-electron:
needs:
- build-cli
- version
continue-on-error: false
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
platform_flag: --mac --x64
- host: macos-latest
target: aarch64-apple-darwin
platform_flag: --mac --arm64
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win
- host: "blacksmith-4vcpu-ubuntu-2404"
target: x86_64-unknown-linux-gnu
platform_flag: --linux
- host: "blacksmith-4vcpu-ubuntu-2404"
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
- uses: apple-actions/import-codesign-certs@v2
if: runner.os == 'macOS'
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Setup Apple API Key
if: runner.os == 'macOS'
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
- name: Install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
sudo chmod -R a+rw ~/apt-cache
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Prepare
run: bun ./scripts/prepare.ts
working-directory: packages/desktop-electron
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
working-directory: packages/desktop-electron
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Package and publish
if: needs.version.outputs.release
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
- name: Package (no publish)
if: ${{ !needs.version.outputs.release }}
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4
if: needs.version.outputs.release
with:
name: latest-yml-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/latest*.yml
publish: publish:
needs: needs:
- version - version
- build-cli - build-cli
- build-tauri - build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -400,12 +255,6 @@ jobs:
name: opencode-cli name: opencode-cli
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:
pattern: latest-yml-*
path: /tmp/latest-yml
- name: Cache apt packages (AUR) - name: Cache apt packages (AUR)
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -431,6 +280,4 @@ jobs:
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
AUR_KEY: ${{ secrets.AUR_KEY }} AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml
+2 -10
View File
@@ -8,16 +8,8 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
unit: unit:
name: unit (${{ matrix.settings.name }}) name: unit (linux)
strategy: runs-on: blacksmith-4vcpu-ubuntu-2404
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
- name: windows
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults: defaults:
run: run:
shell: bash shell: bash
+19 -39
View File
@@ -42,17 +42,15 @@ jobs:
throw error; throw error;
} }
// Parse the .td file for vouched and denounced users // Parse the .td file for denounced users
const vouched = new Set();
const denounced = new Map(); const denounced = new Map();
for (const line of content.split('\n')) { for (const line of content.split('\n')) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue; if (!trimmed || trimmed.startsWith('#')) continue;
if (!trimmed.startsWith('-')) continue;
const isDenounced = trimmed.startsWith('-'); const rest = trimmed.slice(1).trim();
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue; if (!rest) continue;
const spaceIdx = rest.indexOf(' '); const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
@@ -67,50 +65,32 @@ jobs:
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue; if (!username) continue;
if (isDenounced) { denounced.set(username.toLowerCase(), reason);
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
} }
// Check if the author is denounced // Check if the author is denounced
const reason = denounced.get(author.toLowerCase()); const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) { if (reason === undefined) {
// Author is denounced — close the issue core.info(`User ${author} is not denounced. Allowing issue.`);
const body = 'This issue has been automatically closed.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
return; return;
} }
// Author is positively vouched — add label // Author is denounced — close the issue
if (!vouched.has(author.toLowerCase())) { const body = 'This issue has been automatically closed.';
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
return;
}
await github.rest.issues.addLabels({ await github.rest.issues.createComment({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: issueNumber, issue_number: issueNumber,
labels: ['Vouched'], body,
}); });
core.info(`Added vouched label to issue #${issueNumber} from ${author}`); await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
+17 -38
View File
@@ -6,7 +6,6 @@ on:
permissions: permissions:
contents: read contents: read
issues: write
pull-requests: write pull-requests: write
jobs: jobs:
@@ -43,17 +42,15 @@ jobs:
throw error; throw error;
} }
// Parse the .td file for vouched and denounced users // Parse the .td file for denounced users
const vouched = new Set();
const denounced = new Map(); const denounced = new Map();
for (const line of content.split('\n')) { for (const line of content.split('\n')) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue; if (!trimmed || trimmed.startsWith('#')) continue;
if (!trimmed.startsWith('-')) continue;
const isDenounced = trimmed.startsWith('-'); const rest = trimmed.slice(1).trim();
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue; if (!rest) continue;
const spaceIdx = rest.indexOf(' '); const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
@@ -68,47 +65,29 @@ jobs:
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue; if (!username) continue;
if (isDenounced) { denounced.set(username.toLowerCase(), reason);
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
} }
// Check if the author is denounced // Check if the author is denounced
const reason = denounced.get(author.toLowerCase()); const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) { if (reason === undefined) {
// Author is denounced — close the PR core.info(`User ${author} is not denounced. Allowing PR.`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
return; return;
} }
// Author is positively vouched — add label // Author is denounced — close the PR
if (!vouched.has(author.toLowerCase())) { await github.rest.issues.createComment({
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: prNumber, issue_number: prNumber,
labels: ['Vouched'], body: 'This pull request has been automatically closed.',
}); });
core.info(`Added vouched label to PR #${prNumber} from ${author}`); await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
@@ -33,6 +33,5 @@ jobs:
with: with:
issue-id: ${{ github.event.issue.number }} issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }} comment-id: ${{ github.event.comment.id }}
roles: admin,maintain
env: env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
-1
View File
@@ -27,4 +27,3 @@ target
opencode-dev opencode-dev
logs/ logs/
*.bun-build *.bun-build
tsconfig.tsbuildinfo
-15
View File
@@ -13,25 +13,10 @@ Requirements:
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure). - Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks. - Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
- Also preserve every term listed in the Do-Not-Translate glossary below. - Also preserve every term listed in the Do-Not-Translate glossary below.
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
- Do not modify fenced code blocks. - Do not modify fenced code blocks.
- Output ONLY the translation (no commentary). - Output ONLY the translation (no commentary).
If the target locale is missing, ask the user to provide it. If the target locale is missing, ask the user to provide it.
If no locale-specific glossary exists, use the global glossary only.
---
# Locale-Specific Glossaries
When a locale glossary exists, use it to:
- Apply preferred wording for recurring UI/docs terms in that locale
- Preserve locale-specific do-not-translate terms and casing decisions
- Prefer natural phrasing over literal translation when the locale file calls it out
- If the repo uses a locale alias slug, apply that file too (for example, `pt-BR` maps to `br.md` in this repo)
Locale guidance does not override code/command preservation rules or the global Do-Not-Translate glossary below.
--- ---
-63
View File
@@ -1,63 +0,0 @@
# Locale Glossaries
Use this folder for locale-specific translation guidance that supplements `.opencode/agent/translator.md`.
The global glossary in `translator.md` remains the source of truth for shared do-not-translate terms (commands, code, paths, product names, etc.). These locale files capture community learnings about phrasing and terminology preferences.
## File Naming
- One file per locale
- Use lowercase locale slugs that match docs locales when possible (for example, `zh-cn.md`, `zh-tw.md`)
- If only language-level guidance exists, use the language code (for example, `fr.md`)
- Some repo locale slugs may be aliases/non-BCP47 for consistency (for example, `br` for Brazilian Portuguese / `pt-BR`)
## What To Put In A Locale File
- **Sources**: PRs/issues/discussions that motivated the guidance
- **Do Not Translate (Locale Additions)**: locale-specific terms or casing decisions
- **Preferred Terms**: recurring UI/docs words with preferred translations
- **Guidance**: tone, style, and consistency notes
- **Avoid** (optional): common literal translations or wording we should avoid
- If the repo uses a locale alias slug, document the alias in **Guidance** (for example, prose may mention `pt-BR` while config/examples use `br`)
Prefer guidance that is:
- Repeated across multiple docs/screens
- Easy to apply consistently
- Backed by a community contribution or review discussion
## Template
```md
# <locale> Glossary
## Sources
- PR #12345: https://github.com/anomalyco/opencode/pull/12345
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing)
## Preferred Terms
| English | Preferred | Notes |
| ------- | --------- | --------- |
| prompt | ... | preferred |
| session | ... | preferred |
## Guidance
- Prefer natural phrasing over literal translation
## Avoid
- Avoid ... when ...
```
## Contribution Notes
- Mark entries as preferred when they may evolve
- Keep examples short
- Add or update the `Sources` section whenever you add a new rule
- Prefer PR-backed guidance over invented term mappings; start with general guidance if no term-level corrections exist yet
-28
View File
@@ -1,28 +0,0 @@
# ar Glossary
## Sources
- PR #9947: https://github.com/anomalyco/opencode/pull/9947
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Arabic phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- For RTL text, treat code, commands, and paths as LTR artifacts and keep their character order unchanged
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Arabic terms for the same recurring UI action once a preferred term is established
-34
View File
@@ -1,34 +0,0 @@
# br Glossary
## Sources
- PR #10086: https://github.com/anomalyco/opencode/pull/10086
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Locale code `br` in repo config, code, and paths (repo alias for Brazilian Portuguese)
## Preferred Terms
These are PR-backed locale naming preferences and may evolve.
| English / Context | Preferred | Notes |
| ---------------------------------------- | ------------------------------ | ------------------------------------------------------------- |
| Brazilian Portuguese (prose locale name) | `pt-BR` | Use standard locale naming in prose when helpful |
| Repo locale slug (code/config) | `br` | PR #10086 uses `br` for consistency/simplicity |
| Browser locale detection | `pt`, `pt-br`, `pt-BR` -> `br` | Preserve this mapping in docs/examples about locale detection |
## Guidance
- This file covers Brazilian Portuguese (`pt-BR`), but the repo locale code is `br`
- Use natural Brazilian Portuguese phrasing over literal translation
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep repo locale identifiers as implemented in code/config (`br`) even when prose mentions `pt-BR`
## Avoid
- Avoid changing repo locale code references from `br` to `pt-br` in code snippets, paths, or config examples
- Avoid mixing Portuguese variants when a Brazilian Portuguese form is established
-33
View File
@@ -1,33 +0,0 @@
# bs Glossary
## Sources
- PR #12283: https://github.com/anomalyco/opencode/pull/12283
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed locale naming preferences and may evolve.
| English / Context | Preferred | Notes |
| ---------------------------------- | ---------- | ------------------------------------------------- |
| Bosnian language label (UI) | `Bosanski` | PR #12283 tested switching language to `Bosanski` |
| Repo locale slug (code/config) | `bs` | Preserve in code, config, paths, and examples |
| Browser locale detection (Bosnian) | `bs` | PR #12283 added `bs` locale auto-detection |
## Guidance
- Use natural Bosnian phrasing over literal translation
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep repo locale references as `bs` in code/config, and use `Bosanski` for the user-facing language name when applicable
## Avoid
- Avoid changing repo locale references from `bs` to another slug in code snippets or config examples
- Avoid translating product and protocol names that are fixed identifiers
-27
View File
@@ -1,27 +0,0 @@
# da Glossary
## Sources
- PR #9821: https://github.com/anomalyco/opencode/pull/9821
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Danish phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Danish terms for the same recurring UI action once a preferred term is established
-27
View File
@@ -1,27 +0,0 @@
# de Glossary
## Sources
- PR #9817: https://github.com/anomalyco/opencode/pull/9817
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural German phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple German terms for the same recurring UI action once a preferred term is established
-27
View File
@@ -1,27 +0,0 @@
# es Glossary
## Sources
- PR #9817: https://github.com/anomalyco/opencode/pull/9817
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Spanish phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Spanish terms for the same recurring UI action once a preferred term is established
-27
View File
@@ -1,27 +0,0 @@
# fr Glossary
## Sources
- PR #9821: https://github.com/anomalyco/opencode/pull/9821
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural French phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple French terms for the same recurring UI action once a preferred term is established
-33
View File
@@ -1,33 +0,0 @@
# ja Glossary
## Sources
- PR #9821: https://github.com/anomalyco/opencode/pull/9821
- PR #13160: https://github.com/anomalyco/opencode/pull/13160
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed wording preferences and may evolve.
| English / Context | Preferred | Notes |
| --------------------------- | ----------------------- | ------------------------------------- |
| WSL integration (UI label) | `WSL連携` | PR #13160 prefers this over `WSL統合` |
| WSL integration description | `WindowsのWSL環境で...` | PR #13160 improved phrasing naturally |
## Guidance
- Prefer natural Japanese phrasing over literal translation
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- In WSL integration text, follow PR #13160 wording direction for more natural Japanese phrasing
## Avoid
- Avoid `WSL統合` in the WSL integration UI context where `WSL連携` is the reviewed wording
- Avoid translating product and protocol names that are fixed identifiers
-27
View File
@@ -1,27 +0,0 @@
# ko Glossary
## Sources
- PR #9817: https://github.com/anomalyco/opencode/pull/9817
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Korean phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Korean terms for the same recurring UI action once a preferred term is established
-38
View File
@@ -1,38 +0,0 @@
# no Glossary
## Sources
- PR #10018: https://github.com/anomalyco/opencode/pull/10018
- PR #12935: https://github.com/anomalyco/opencode/pull/12935
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Sound names (PR #10018 notes these were intentionally left untranslated)
## Preferred Terms
These are PR-backed corrections and may evolve.
| English / Context | Preferred | Notes |
| ----------------------------------- | ------------ | ----------------------------- |
| Save (data persistence action) | `Lagre` | Prefer over `Spare` |
| Disabled (feature/state) | `deaktivert` | Prefer over `funksjonshemmet` |
| API keys | `API Nøkler` | Prefer over `API Taster` |
| Cost (noun) | `Kostnad` | Prefer over verb form `Koste` |
| Show/View (imperative button label) | `Vis` | Prefer over `Utsikt` |
## Guidance
- Prefer natural Norwegian Bokmal (Bokmål) wording over literal translation
- Keep tone clear and practical in UI labels
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep recurring UI terms consistent once a preferred term is chosen
## Avoid
- Avoid `Spare` for save actions in persistence contexts
- Avoid `funksjonshemmet` for disabled feature states
- Avoid `API Taster`, `Koste`, and `Utsikt` in the corrected contexts above
-27
View File
@@ -1,27 +0,0 @@
# pl Glossary
## Sources
- PR #9884: https://github.com/anomalyco/opencode/pull/9884
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Polish phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Polish terms for the same recurring UI action once a preferred term is established
-27
View File
@@ -1,27 +0,0 @@
# ru Glossary
## Sources
- PR #9882: https://github.com/anomalyco/opencode/pull/9882
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Russian phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Russian terms for the same recurring UI action once a preferred term is established
-34
View File
@@ -1,34 +0,0 @@
# th Glossary
## Sources
- PR #10809: https://github.com/anomalyco/opencode/pull/10809
- PR #11496: https://github.com/anomalyco/opencode/pull/11496
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code)
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed preferences and may evolve.
| English / Context | Preferred | Notes |
| ------------------------------------- | --------------------- | -------------------------------------------------------------------------------- |
| Thai language label in language lists | `ไทย` | PR #10809 standardized this across locales |
| Language names in language pickers | Native names (static) | PR #11496: keep names like `English`, `Deutsch`, `ไทย` consistent across locales |
## Guidance
- Prefer natural Thai phrasing over literal translation
- Keep tone short and clear for buttons and labels
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep language names static/native in language pickers instead of translating them per current locale (PR #11496)
## Avoid
- Avoid translating language names differently per current locale in language lists
- Avoid changing `ไทย` to another display form for the Thai language option unless the product standard changes
-38
View File
@@ -1,38 +0,0 @@
# tr Glossary
## Sources
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose, docs, and UI copy)
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed wording preferences and may evolve.
| English / Context | Preferred | Notes |
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
## Guidance
- Prefer natural Turkish phrasing over literal translation
- Merge broken sentence fragments into one clear sentence when the source is a single thought
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
## Avoid
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
-42
View File
@@ -1,42 +0,0 @@
# zh-cn Glossary
## Sources
- PR #13942: https://github.com/anomalyco/opencode/pull/13942
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only when it is part of commands, package names, paths, or code)
- `OpenCode Zen`
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- `Model Context Protocol` (prefer the English expansion when introducing `MCP`)
## Preferred Terms
These are preferred terms for docs/UI prose and may evolve.
| English | Preferred | Notes |
| ----------------------- | --------- | ------------------------------------------- |
| prompt | 提示词 | Keep `--prompt` unchanged in flags/code |
| session | 会话 | |
| provider | 提供商 | |
| share link / shared URL | 分享链接 | Prefer `分享` for user-facing share actions |
| headless (server) | 无界面 | Docs wording |
| authentication | 认证 | Prefer in auth/OAuth contexts |
| cache | 缓存 | |
| keybind / shortcut | 快捷键 | User-facing docs wording |
| workflow | 工作流 | e.g. GitHub Actions workflow |
## Guidance
- Prefer natural, concise phrasing over literal translation
- Keep the tone direct and friendly (PR #13942 consistently moved wording in this direction)
- Preserve technical artifacts exactly: commands, flags, code, inline code, URLs, file paths, model IDs
- Keep enum-like values in English when they are literals (for example, `default`, `json`)
- Prefer consistent terminology across pages once a term is chosen (`会话`, `提供商`, `提示词`, etc.)
## Avoid
- Avoid `opencode` in prose when referring to the product name; use `OpenCode`
- Avoid mixing alternative terms for the same concept across docs when a preferred term is already established
-42
View File
@@ -1,42 +0,0 @@
# zh-tw Glossary
## Sources
- PR #13942: https://github.com/anomalyco/opencode/pull/13942
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose; keep `opencode` only when it is part of commands, package names, paths, or code)
- `OpenCode Zen`
- `OpenCode CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- `Model Context Protocol` (prefer the English expansion when introducing `MCP`)
## Preferred Terms
These are preferred terms for docs/UI prose and may evolve.
| English | Preferred | Notes |
| ----------------------- | --------- | ------------------------------------------- |
| prompt | 提示詞 | Keep `--prompt` unchanged in flags/code |
| session | 工作階段 | |
| provider | 供應商 | |
| share link / shared URL | 分享連結 | Prefer `分享` for user-facing share actions |
| headless (server) | 無介面 | Docs wording |
| authentication | 認證 | Prefer in auth/OAuth contexts |
| cache | 快取 | |
| keybind / shortcut | 快捷鍵 | User-facing docs wording |
| workflow | 工作流程 | e.g. GitHub Actions workflow |
## Guidance
- Prefer natural, concise phrasing over literal translation
- Keep the tone direct and friendly (PR #13942 consistently moved wording in this direction)
- Preserve technical artifacts exactly: commands, flags, code, inline code, URLs, file paths, model IDs
- Keep enum-like values in English when they are literals (for example, `default`, `json`)
- Prefer consistent terminology across pages once a term is chosen (`工作階段`, `供應商`, `提示詞`, etc.)
## Avoid
- Avoid `opencode` in prose when referring to the product name; use `OpenCode`
- Avoid mixing alternative terms for the same concept across docs when a preferred term is already established
+7 -13
View File
@@ -5,16 +5,8 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"], zen: ["fwang", "MrMushrooooom"],
tui: [ tui: ["thdxr", "kommander", "rekram1-node"],
"thdxr", core: ["thdxr", "rekram1-node", "jlongster"],
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
docs: ["R44VC0RP"], docs: ["R44VC0RP"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
@@ -50,7 +42,10 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({ export default tool({
description: DESCRIPTION, description: DESCRIPTION,
args: { args: {
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"), assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.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", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue") .describe("The labels(s) to add to the issue")
@@ -73,8 +68,7 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)") results.push("Dropped label: nix (issue does not mention nix)")
} }
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) { if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'") throw new Error("Only add the zen label when issue title/body contains 'zen'")
-2
View File
@@ -4,5 +4,3 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner. Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random. If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)
-11
View File
@@ -20,17 +20,6 @@
Prefer single word names for variables and functions. Only use multiple words if necessary. Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts ```ts
// Good // Good
const foo = 1 const foo = 1
+1 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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)
-141
View File
@@ -1,141 +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">ওপেন সোর্স এআই কোডিং এজেন্ট।</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> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### ইনস্টলেশন (Installation)
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```
> [!TIP]
> ইনস্টল করার আগে ০.১.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`, or AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### ইনস্টলেশন ডিরেক্টরি (Installation Directory)
ইনস্টল স্ক্রিপ্টটি ইনস্টলেশন পাতের জন্য নিম্নলিখিত অগ্রাধিকার ক্রম মেনে চলে:
1. `$OPENCODE_INSTALL_DIR` - কাস্টম ইনস্টলেশন ডিরেক্টরি
2. `$XDG_BIN_DIR` - XDG বেস ডিরেক্টরি স্পেসিফিকেশন সমর্থিত পাথ
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
```
### এজেন্টস (Agents)
OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়েছে যা আপনি `Tab` কি(key) দিয়ে পরিবর্তন করতে পারবেন।
- **build** - ডিফল্ট, ডেভেলপমেন্টের কাজের জন্য সম্পূর্ণ অ্যাক্সেসযুক্ত এজেন্ট
- **plan** - বিশ্লেষণ এবং কোড এক্সপ্লোরেশনের জন্য রিড-ওনলি এজেন্ট
- ডিফল্টভাবে ফাইল এডিট করতে দেয় না
- ব্যাশ কমান্ড চালানোর আগে অনুমতি চায়
- অপরিচিত কোডবেস এক্সপ্লোর করা বা পরিবর্তনের পরিকল্পনা করার জন্য আদর্শ
এছাড়াও জটিল অনুসন্ধান এবং মাল্টিস্টেপ টাস্কের জন্য একটি **general** সাবএজেন্ট অন্তর্ভুক্ত রয়েছে।
এটি অভ্যন্তরীণভাবে ব্যবহৃত হয় এবং মেসেজে `@general` লিখে ব্যবহার করা যেতে পারে।
এজেন্টদের সম্পর্কে আরও জানুন: [docs](https://opencode.ai/docs/agents)।
### ডকুমেন্টেশন (Documentation)
কিভাবে OpenCode কনফিগার করবেন সে সম্পর্কে আরও তথ্যের জন্য, [**আমাদের ডকস দেখুন**](https://opencode.ai/docs)।
### অবদান (Contributing)
আপনি যদি OpenCode এ অবদান রাখতে চান, অনুগ্রহ করে একটি পুল রিকোয়েস্ট সাবমিট করার আগে আমাদের [কন্ট্রিবিউটিং ডকস](./CONTRIBUTING.md) পড়ে নিন।
### OpenCode এর উপর বিল্ডিং (Building on OpenCode)
আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই।
### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা?
ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো:
- ১০০% ওপেন সোর্স
- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -4
View File
@@ -33,10 +33,7 @@
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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)
-141
View File
@@ -1,141 +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">Ο πράκτορας τεχνητής νοημοσύνης ανοικτού κώδικα για προγραμματισμό.</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> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 (επίσημος τύπος brew, λιγότερο συχνές ενημερώσεις)
sudo pacman -S opencode # Arch Linux (Σταθερό)
paru -S opencode-bin # Arch Linux (Τελευταία έκδοση από AUR)
mise use -g opencode # Οποιοδήποτε λειτουργικό σύστημα
nix run nixpkgs#opencode # ή github:anomalyco/opencode με βάση την πιο πρόσφατη αλλαγή από το dev branch
```
> [!TIP]
> Αφαίρεσε παλαιότερες εκδόσεις από τη 0.1.x πριν από την εγκατάσταση.
### Εφαρμογή Desktop (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
```
#### Κατάλογος Εγκατάστασης
Το script εγκατάστασης τηρεί την ακόλουθη σειρά προτεραιότητας για τη διαδρομή εγκατάστασης:
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` στα μηνύματα.
Μάθετε περισσότερα για τους [πράκτορες](https://opencode.ai/docs/agents).
### Οδηγός Χρήσης
Για περισσότερες πληροφορίες σχετικά με τη ρύθμιση του OpenCode, [**πλοηγήσου στον οδηγό χρήσης μας**](https://opencode.ai/docs).
### Συνεισφορά
Εάν ενδιαφέρεσαι να συνεισφέρεις στο OpenCode, διαβάστε τα [οδηγό χρήσης συνεισφοράς](./CONTRIBUTING.md) πριν υποβάλεις ένα pull request.
### Δημιουργία πάνω στο OpenCode
Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς.
### Συχνές Ερωτήσεις
#### Πώς διαφέρει αυτό από το Claude Code;
Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές:
- 100% ανοιχτού κώδικα
- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο.
- Out-of-the-box υποστήριξη LSP
- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal.
- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients.
---
**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+1 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -4
View File
@@ -33,10 +33,7 @@
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -4
View File
@@ -33,10 +33,7 @@
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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)
-141
View File
@@ -1,141 +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">Trợ lý lập trình AI mã nguồn mở.</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> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Cài đặt
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Các trình quản lý gói (Package managers)
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
sudo pacman -S opencode # Arch Linux (Bản ổn định)
paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR)
mise use -g opencode # Mọi hệ điều hành
nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
```
> [!TIP]
> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
### Ứng dụng Desktop (BETA)
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
| Nền tảng | Tải xuống |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, hoặc AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Thư mục cài đặt
Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
4. `$HOME/.opencode/bin` - Mặc định dự phòng
```bash
# Ví dụ
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
```
### Agents (Đại diện)
OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
- Mặc định từ chối việc chỉnh sửa tệp
- Hỏi quyền trước khi chạy các lệnh bash
- Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
### Tài liệu
Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
### Đóng góp
Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
### Xây dựng trên nền tảng OpenCode
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
### Các câu hỏi thường gặp (FAQ)
#### OpenCode khác biệt thế nào so với Claude Code?
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
- 100% mã nguồn mở
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
- Hỗ trợ LSP ngay từ đầu
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
---
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+1 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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 -5
View File
@@ -27,16 +27,12 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<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> | <a href="README.uk.md">Українська</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</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)
+61 -926
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": 1772091128, "lastModified": 1770812194,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=", "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3f0336406035444b4a24b942788334af5f906259", "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"type": "github" "type": "github"
}, },
"original": { "original": {
-5
View File
@@ -30,10 +30,6 @@ inputs:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'" description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false required: false
variant:
description: "Model variant for provider-specific reasoning effort (e.g., high, max, minimal)"
required: false
oidc_base_url: oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai" description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
required: false required: false
@@ -75,5 +71,4 @@ runs:
PROMPT: ${{ inputs.prompt }} PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }} MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }} OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
+1 -2
View File
@@ -8,7 +8,6 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types" import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process" import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = { type GitHubAuthor = {
login: string login: string
@@ -282,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true connected = true
break break
} catch (e) {} } catch (e) {}
await sleep(300) await Bun.sleep(300)
} while (retry++ < 30) } while (retry++ < 30)
if (!connected) { if (!connected) {
+12 -31
View File
@@ -100,47 +100,29 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
], ],
}) })
const zenLiteProduct = new stripe.Product("ZenLite", { const zenProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Go",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
recurring: {
interval: "month",
intervalCount: 1,
},
unitAmount: 1000,
})
const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
},
})
const zenBlackProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black", name: "OpenCode Black",
}) })
const zenBlackPriceProps = { const zenPriceProps = {
product: zenBlackProduct.id, product: zenProduct.id,
currency: "usd", currency: "usd",
recurring: { recurring: {
interval: "month", interval: "month",
intervalCount: 1, intervalCount: 1,
}, },
} }
const zenBlackPrice200 = new stripe.Price("ZenBlackPrice", { ...zenBlackPriceProps, unitAmount: 20000 }) const zenPrice200 = new stripe.Price("ZenBlackPrice", { ...zenPriceProps, unitAmount: 20000 })
const zenBlackPrice100 = new stripe.Price("ZenBlack100Price", { ...zenBlackPriceProps, unitAmount: 10000 }) const zenPrice100 = new stripe.Price("ZenBlack100Price", { ...zenPriceProps, unitAmount: 10000 })
const zenBlackPrice20 = new stripe.Price("ZenBlack20Price", { ...zenBlackPriceProps, unitAmount: 2000 }) const zenPrice20 = new stripe.Price("ZenBlack20Price", { ...zenPriceProps, unitAmount: 2000 })
const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", { const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
properties: { properties: {
product: zenBlackProduct.id, product: zenProduct.id,
plan200: zenBlackPrice200.id, plan200: zenPrice200.id,
plan100: zenBlackPrice100.id, plan100: zenPrice100.id,
plan20: zenBlackPrice20.id, plan20: zenPrice20.id,
}, },
}) })
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
const ZEN_MODELS = [ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"), new sst.Secret("ZEN_MODELS1"),
@@ -213,8 +195,7 @@ new sst.cloudflare.x.SolidStart("Console", {
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK_PRICE, ZEN_BLACK_PRICE,
ZEN_LITE_PRICE, ZEN_BLACK_LIMITS,
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"), new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS, ...ZEN_MODELS,
...($dev ...($dev
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=", "x86_64-linux": "sha256-fjrvCgQ2PHYxzw8NsiEHOcor46qN95/cfilFHFqCp/k=",
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=", "aarch64-linux": "sha256-xWp4LLJrbrCPFL1F6SSbProq/t/az4CqhTcymPvjOBQ=",
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=", "aarch64-darwin": "sha256-Wbfyy/bruFHKUWsyJ2aiPXAzLkk5MNBfN6QdGPQwZS0=",
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE=" "x86_64-darwin": "sha256-wDnMbiaBCRj5STkaLoVCZTdXVde+/YKfwWzwJZ1AJXQ="
} }
} }
-1
View File
@@ -31,7 +31,6 @@ stdenvNoCC.mkDerivation {
../package.json ../package.json
../patches ../patches
../install # required by desktop build (cli.rs include_str!) ../install # required by desktop build (cli.rs include_str!)
../.github/TEAM_MEMBERS # required by @opencode-ai/script
] ]
); );
}; };
+6 -9
View File
@@ -4,12 +4,11 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.10", "packageManager": "bun@1.3.9",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
@@ -36,13 +35,13 @@
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.13",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@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.16-ea816b6", "drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -71,13 +70,12 @@
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5", "glob": "13.0.5",
"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.18.10",
"turbo": "2.8.13" "turbo": "2.5.6"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
@@ -100,8 +98,7 @@
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
"web-tree-sitter", "web-tree-sitter"
"electron"
], ],
"overrides": { "overrides": {
"@types/bun": "catalog:", "@types/bun": "catalog:",
@@ -1,515 +0,0 @@
# CreateEffect Simplification Implementation Spec
Reduce reactive misuse across `packages/app`.
---
## Context
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
Key issues from the audit:
- Derived state is being written through effects instead of computed directly
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
- User-driven actions are hidden inside reactive effects
- Context layers mirror and hydrate child stores with multiple sync effects
- Several areas repeat the same imperative trigger pattern in multiple effects
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
## Goals
- Cut high-churn `createEffect` usage in the hottest files first
- Replace effect-driven derived state with reactive derivation
- Replace reset-on-key effects with keyed ownership boundaries
- Move event-driven work to direct actions and write paths
- Remove mirrored store hydration where a single source of truth can exist
- Leave necessary external sync effects in place, but make them narrower and clearer
## Non-Goals
- Do not rewrite unrelated component structure just to reduce the count
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
- Do not attempt a repo-wide cleanup outside `packages/app`
## Effect Taxonomy And Replacement Rules
Use these rules during implementation.
### Prefer `createMemo`
Use `createMemo` when the target value is pure derived state from other signals or stores.
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
Apply this to:
- `packages/app/src/pages/session.tsx:141`
- `packages/app/src/pages/layout.tsx:557`
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Rules:
- If no external system is touched, do not use `createEffect`
- Derive once, then read the memo where needed
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
### Prefer Keyed Remounts
Use keyed remounts when local UI state should reset because an identity changed.
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
Apply this to:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
- `packages/app/src/context/file.tsx:100`
Rules:
- If the desired behavior is "new identity, fresh local state," key the owner subtree
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
### Prefer Event Handlers And Actions
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
Apply this to:
- `packages/app/src/pages/layout.tsx:484`
- `packages/app/src/pages/layout.tsx:652`
- `packages/app/src/pages/layout.tsx:776`
- `packages/app/src/pages/layout.tsx:1489`
- `packages/app/src/pages/layout.tsx:1519`
- `packages/app/src/components/file-tree.tsx:328`
- `packages/app/src/pages/session/terminal-panel.tsx:55`
- `packages/app/src/context/global-sync.tsx:148`
- Duplicated trigger sets in:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Rules:
- If the trigger is user intent, call the action at the source of that intent
- If the same imperative work is triggered from multiple places, extract one function and call it directly
### Prefer `onMount` And `onCleanup`
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
Use this when:
- Setup should happen once per owner lifecycle
- Cleanup should always pair with teardown
- The work is not conceptually derived state
### Keep `createEffect` When It Is A Real Bridge
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
Examples that should remain, though they may be narrowed or split:
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
- Scroll sync in `packages/app/src/pages/session.tsx:685`
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- External sync in:
- `packages/app/src/context/language.tsx:207`
- `packages/app/src/context/settings.tsx:110`
- `packages/app/src/context/sdk.tsx:26`
- Polling in:
- `packages/app/src/components/status-popover.tsx:59`
- `packages/app/src/components/dialog-select-server.tsx:273`
Rules:
- Keep the effect single-purpose
- Make dependencies explicit and narrow
- Avoid writing back into the same reactive graph unless absolutely required
## Implementation Plan
### Phase 0: Classification Pass
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
Acceptance criteria:
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
- Shared helpers to be introduced are identified up front to avoid repeating patterns
### Phase 1: Derived-State Cleanup
Tackle highest-value, lowest-risk derived-state cleanup first.
Priority items:
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
- Replace other obvious derived-state effects in terminal and session header
Acceptance criteria:
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
- Targeted derived-state effects are deleted, not just moved
### Phase 2: Keyed Reset Cleanup
Replace reset-on-key effects with keyed ownership boundaries.
Priority items:
- Key session-scoped UI and state by `sessionKey`
- Key file-scoped state by `scope()`
- Remove manual clear-and-reseed effects in session and file context
Acceptance criteria:
- Switching session or file scope recreates the intended local state cleanly
- No stale state leaks across session or scope changes
- Target reset effects are deleted
### Phase 3: Event-Driven Work Extraction
Move event-driven work out of reactive effects.
Priority items:
- Replace `globalStore.reload` effect dispatching with direct calls
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
- Collapse duplicated imperative trigger triplets into single functions
- Move file-tree and terminal-panel imperative work to explicit handlers
Acceptance criteria:
- User-triggered behavior still fires exactly once per intended action
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
### Phase 4: Context Ownership Cleanup
Remove mirrored child-store hydration patterns.
Priority items:
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
Acceptance criteria:
- There is one clear source of truth for each synced value
- Child stores no longer need effect-based hydration to stay consistent
- Initialization and updates both work without manual mirror effects
### Phase 5: Cleanup And Keeper Review
Clean up remaining targeted hotspots and narrow the effects that should stay.
Acceptance criteria:
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
- Mixed-responsibility effects are split into smaller units where still needed
## Detailed Work Items By Area
### 1. Normalize Tab State
Files:
- `packages/app/src/pages/session.tsx:141`
Work:
- Move tab normalization into the functions that create, load, or update tab state
- Make readers consume already-normalized tab data
- Remove the effect that rewrites derived tab state after the fact
Rationale:
- Tabs should become valid when written, not be repaired later
- This removes a feedback loop and makes state easier to trust
Acceptance criteria:
- The effect at `packages/app/src/pages/session.tsx:141` is removed
- Newly created and restored tabs are normalized before they enter local state
- Tab rendering still matches current behavior for valid and edge-case inputs
### 2. Key Session-Owned State
Files:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
Work:
- Identify state that should reset when `sessionKey` changes
- Move that state under a keyed subtree or keyed owner boundary
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
Rationale:
- Session identity already defines the lifetime of this UI state
- Keyed ownership makes reset behavior automatic and easier to reason about
Acceptance criteria:
- The targeted reset effects are removed
- Changing sessions resets only the intended session-local state
- Scroll and editor state that should persist are not accidentally reset
### 3. Derive Workspace Order
Files:
- `packages/app/src/pages/layout.tsx:557`
Work:
- Stop writing `workspaceOrder` from live workspace data in an effect
- Represent user overrides separately from live workspace data
- Compute effective order from current data plus overrides with a memo or pure helper
Rationale:
- Persisted user intent and live source data should not mirror each other through an effect
- A computed effective order avoids drift and racey resync behavior
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
- User overrides persist without requiring a sync-back effect
### 4. Remove Child-Store Mirrors
Files:
- `packages/app/src/context/global-sync.tsx:130`
- `packages/app/src/context/global-sync.tsx:138`
- `packages/app/src/context/global-sync.tsx:148`
- `packages/app/src/context/global-sync/child-store.ts:184`
- `packages/app/src/context/global-sync/child-store.ts:190`
- `packages/app/src/context/global-sync/child-store.ts:193`
- `packages/app/src/context/layout.tsx:424`
Work:
- Trace the actual ownership of global and child store values
- Replace hydration and mirror effects with explicit initialization and direct updates
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
Rationale:
- Mirrors make it hard to tell which state is authoritative
- Event-bus style state toggles hide control flow and create accidental reruns
Acceptance criteria:
- Child store hydration no longer depends on effect-based copying
- Reload work can be followed from the event source to the handler without a reactive relay
- State remains correct on first load, child creation, and subsequent updates
### 5. Key File-Scoped State
Files:
- `packages/app/src/context/file.tsx:100`
Work:
- Move file-scoped local state under a boundary keyed by `scope()`
- Remove any effect that watches `scope()` only to reset file-local state
Rationale:
- File scope changes are identity changes
- Keyed ownership gives a cleaner reset than manual clear logic
Acceptance criteria:
- The effect at `packages/app/src/context/file.tsx:100` is removed
- Switching scopes resets only scope-local state
- No previous-scope data appears after a scope change
### 6. Split Layout Side Effects
Files:
- `packages/app/src/pages/layout.tsx:1489`
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
Work:
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
- Move user-triggered branches into the actual command or handler that causes them
- Remove any branch that only exists because one effect is handling unrelated concerns
Rationale:
- Mixed effects hide cause and make reruns hard to predict
- Smaller units reduce accidental coupling and make future cleanup safer
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
- Event-driven branches execute from direct handlers
- Remaining effects in this area each have one clear external sync purpose
### 7. Remove Duplicate Triggers
Files:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Work:
- Extract one explicit imperative function per behavior
- Call that function from each source event instead of replicating the same effect pattern multiple times
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
Rationale:
- Duplicate triggers make it easy to miss a case or fire twice
- One named action is easier to test and reason about
Acceptance criteria:
- Repeated imperative effect triplets are collapsed into shared functions
- Scroll behavior still works, including hash-based navigation
- No duplicate firing is introduced
### 8. Make Prompt Filtering Reactive
Files:
- `packages/app/src/components/prompt-input.tsx:652`
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
Work:
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
Rationale:
- Filtering is classic derived state
- It should not need an effect if it can be computed from current inputs
Acceptance criteria:
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
- Filtered slash-command results update correctly as the input changes
- The editor sync effect at `:690` still behaves correctly
### 9. Clean Up Smaller Derived-State Cases
Files:
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Work:
- Replace effect-written local state with memos or inline derivation
- Remove intermediate setters when the value can be computed directly
Rationale:
- These are low-risk wins that reinforce the same pattern
- They also help keep follow-up cleanup consistent
Acceptance criteria:
- Targeted effects are removed
- UI output remains unchanged under the same inputs
## Verification And Regression Checks
Run focused checks after each phase, not only at the end.
### Suggested Verification
- Switch between sessions rapidly and confirm local session UI resets only where intended
- Open, close, and reorder tabs and confirm order and normalization remain stable
- Change workspaces, reload workspace data, and verify effective ordering is correct
- Change file scope and confirm stale file state does not bleed across scopes
- Trigger layout actions that previously depended on effects and confirm they still fire once
- Use slash commands in the prompt and verify filtering updates as you type
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
- Verify global sync initialization, reload, and child-store creation paths
### Regression Checks
- No accidental infinite reruns
- No double-firing network or command actions
- No lost cleanup for listeners, timers, or scroll handlers
- No preserved stale state after identity changes
- No removed effect that was actually bridging to DOM or an external API
If available, add or update tests around pure helpers introduced during this cleanup.
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
## Definition Of Done
This work is done when all of the following are true:
- The highest-leverage targets in this spec are implemented
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
- The "should remain" effects still exist only where they serve a real external sync purpose
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
- No behavior regressions are found in the targeted areas
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
The main success metric is clearer ownership and fewer effect-driven state repairs.
## Risks And Rollout Notes
Main risks:
- Keyed remounts can reset too much if state boundaries are drawn too high
- Store mirror removal can break initialization order if ownership is not mapped first
- Moving event work out of effects can accidentally skip triggers that were previously implicit
Rollout notes:
- Land in small phases, with each phase keeping the app behaviorally stable
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
+4 -56
View File
@@ -8,7 +8,6 @@ import {
sessionItemSelector, sessionItemSelector,
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
sessionHeaderSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
projectWorkspacesToggleSelector, projectWorkspacesToggleSelector,
titlebarRightSelector, titlebarRightSelector,
@@ -226,9 +225,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) { export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const header = page.locator(sessionHeaderSelector).first() const scroller = page.locator(".session-scroller").first()
await expect(header).toBeVisible() await expect(scroller).toBeVisible()
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page const menu = page
.locator(dropdownMenuContentSelector) .locator(dropdownMenuContentSelector)
@@ -244,7 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu if (opened) return menu
const menuTrigger = header.getByRole("button", { name: /more options/i }).first() const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible() await expect(menuTrigger).toBeVisible()
await menuTrigger.click() await menuTrigger.click()
@@ -442,57 +441,6 @@ export async function seedSessionPermission(
return { id: result.id } return { id: result.id }
} }
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part) return
const id = part.state.metadata?.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function seedSessionTodos( export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>, sdk: ReturnType<typeof createSdk>,
input: { input: {
+3 -3
View File
@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click() await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true") await expect(tab).toHaveAttribute("aria-selected", "true")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first() const code = page.locator('[data-component="code"]').first()
await expect(viewer).toBeVisible() await expect(code).toBeVisible()
await expect(viewer).toContainText("export default function FileTree") await expect(code).toContainText("export default function FileTree")
}) })
+3 -110
View File
@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
@@ -44,113 +43,7 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first() const code = page.locator('[data-component="code"]').first()
await expect(viewer).toBeVisible() await expect(code).toBeVisible()
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await viewer.click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
}) })
@@ -9,7 +9,7 @@ import {
sessionIDFromUrl, sessionIDFromUrl,
} from "../actions" } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk, dirSlug, sessionPath } from "../utils" import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) { function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -51,6 +51,7 @@ test("switching back to a project opens the latest workspace session", async ({
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
const stamp = Date.now()
let rootDir: string | undefined let rootDir: string | undefined
let workspaceDir: string | undefined let workspaceDir: string | undefined
let sessionID: string | undefined let sessionID: string | undefined
@@ -79,7 +80,6 @@ test("switching back to a project opens the latest workspace session", async ({
const workspaceSlug = slugFromUrl(page.url()) const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug) workspaceDir = base64Decode(workspaceSlug)
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
await openSidebar(page) await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first() const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
@@ -92,19 +92,15 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
// Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await prompt.fill("test") await prompt.fill(`project switch remembers workspace ${stamp}`)
await page.keyboard.press("Enter") await prompt.press("Enter")
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url()) const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
sessionID = created sessionID = created
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page) await openSidebar(page)
@@ -118,8 +114,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click() await rootButton.click()
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
{ extra: [other] }, { extra: [other] },
) )
+3 -2
View File
@@ -20,8 +20,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
@@ -53,8 +56,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]' export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionHeaderSelector = "[data-session-title]"
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) => export const workspaceItemSelector = (slug: string) =>
@@ -1,37 +0,0 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
})
})
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
import { import {
permissionDockSelector, permissionDockSelector,
promptSelector, promptSelector,
@@ -11,23 +11,11 @@ import {
} from "../selectors" } from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0] type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
async function withDockSession<T>( async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
sdk: Sdk, const session = await sdk.session.create({ title }).then((r) => r.data)
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id") if (!session?.id) throw new Error("Session create did not return an id")
try { return fn(session)
return await fn(session)
} finally {
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
}
} }
test.setTimeout(120_000) test.setTimeout(120_000)
@@ -40,94 +28,6 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
} }
} }
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
for (let i = 0; i < 3; i++) {
const count = await dock.count()
if (count === 0) return
await dock.getByRole("button", { name: label }).click()
await page.waitForTimeout(150)
}
}
async function setAutoAccept(page: any, enabled: boolean) {
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
const pressed = (await button.getAttribute("aria-pressed")) === "true"
if (pressed === enabled) return
await button.click()
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function withMockPermission<T>(
page: any,
request: {
id: string
sessionID: string
permission: string
patterns: string[]
metadata?: Record<string, unknown>
always?: string[]
},
opts: { child?: any } | undefined,
fn: () => Promise<T>,
) {
let pending = [
{
...request,
always: request.always ?? ["*"],
metadata: request.metadata ?? {},
},
]
const list = async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
})
}
const reply = async (route: any) => {
const url = new URL(route.request().url())
const id = url.pathname.split("/").pop()
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(true),
})
}
await page.route("**/permission", list)
await page.route("**/session/*/permissions/*", reply)
const sessionList = opts?.child
? async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
: undefined
if (sessionList) await page.route("**/session?*", sessionList)
try {
return await fn()
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => { test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => { await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id) await gotoSession(session.id)
@@ -142,17 +42,6 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
}) })
}) })
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => { test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => { await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => { await withDockSeed(sdk, session.id, async () => {
@@ -187,179 +76,72 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => { test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => { await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await gotoSession(session.id) await withDockSeed(sdk, session.id, async () => {
await setAutoAccept(page, false) await gotoSession(session.id)
await withMockPermission(
page, await seedSessionPermission(sdk, {
{
id: "per_e2e_once",
sessionID: session.id, sessionID: session.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"], patterns: ["README.md"],
metadata: { description: "Need permission for command" }, description: "Need permission for command",
}, })
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i) await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await page.goto(page.url()) await expect(page.locator(promptSelector)).toHaveCount(0)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible() await page
}, .locator(permissionDockSelector)
) .getByRole("button", { name: /allow once/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
}) })
}) })
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => { test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => { await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await gotoSession(session.id) await withDockSeed(sdk, session.id, async () => {
await setAutoAccept(page, false) await gotoSession(session.id)
await withMockPermission(
page, await seedSessionPermission(sdk, {
{
id: "per_e2e_reject",
sessionID: session.id, sessionID: session.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"], patterns: ["REJECT.md"],
}, })
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /deny/i) await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await page.goto(page.url()) await expect(page.locator(promptSelector)).toHaveCount(0)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible() await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
}, await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
) await expect(page.locator(promptSelector)).toBeVisible()
})
}) })
}) })
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => { test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => { await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await gotoSession(session.id) await withDockSeed(sdk, session.id, async () => {
await setAutoAccept(page, false) await gotoSession(session.id)
await withMockPermission(
page, await seedSessionPermission(sdk, {
{
id: "per_e2e_always",
sessionID: session.id, sessionID: session.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"], patterns: ["README.md"],
metadata: { description: "Need permission for command" }, description: "Need permission for command",
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow always/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
}) })
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try { await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await withDockSeed(sdk, child.id, async () => { await expect(page.locator(promptSelector)).toHaveCount(0)
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
const dock = page.locator(questionDockSelector) await page
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) .locator(permissionDockSelector)
await expect(page.locator(promptSelector)).toHaveCount(0) .getByRole("button", { name: /allow always/i })
.click()
await dock.locator('[data-slot="question-option"]').first().click() await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await dock.getByRole("button", { name: /submit/i }).click() await expect(page.locator(promptSelector)).toBeVisible()
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
} finally {
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
}
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
const child = await sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async () => {
await page.goto(page.url())
const dock = page.locator(permissionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
} finally {
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
}
}) })
}) })
+2 -2
View File
@@ -7,7 +7,7 @@ import {
openSharePopover, openSharePopover,
withSession, withSession,
} from "../actions" } from "../actions"
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors" import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first() const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toBeFocused() await expect(input).toBeFocused()
await input.fill(renamedTitle) await input.fill(renamedTitle)
+8 -5
View File
@@ -9,6 +9,7 @@ import {
settingsNotificationsPermissionsSelector, settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector, settingsReleaseNotesSelector,
settingsSoundsAgentSelector, settingsSoundsAgentSelector,
settingsSoundsAgentEnabledSelector,
settingsSoundsErrorsSelector, settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector, settingsSoundsPermissionsSelector,
settingsThemeSelector, settingsThemeSelector,
@@ -335,19 +336,21 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01") expect(stored?.sounds?.agent).not.toBe("staplebops-01")
}) })
test("selecting none disables agent sound", async ({ page, gotoSession }) => { test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
const dialog = await openSettings(page) const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector) const select = dialog.locator(settingsSoundsAgentSelector)
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]') const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible() await expect(select).toBeVisible()
await expect(switchContainer).toBeVisible()
await expect(trigger).toBeEnabled() await expect(trigger).toBeEnabled()
await trigger.click() await switchContainer.locator('[data-slot="switch-control"]').click()
const items = page.locator('[data-slot="select-select-item"]') await page.waitForTimeout(100)
await expect(items.first()).toBeVisible()
await items.first().click() await expect(trigger).toBeDisabled()
const stored = await page.evaluate((key) => { const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key) const raw = localStorage.getItem(key)
@@ -6,7 +6,6 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await gotoSession() await gotoSession()
const terminals = page.locator(terminalSelector) const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible() const opened = await terminals.first().isVisible()
if (!opened) { if (!opened) {
@@ -22,7 +21,6 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T") await page.keyboard.press("Control+Alt+T")
await expect(tabs).toHaveCount(2) await expect(terminals).toHaveCount(2)
await expect(terminals).toHaveCount(1) await expect(terminals.nth(1).locator("textarea")).toHaveCount(1)
await expect(terminals.first().locator("textarea")).toHaveCount(1)
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}` export const serverUrl = `http://${serverHost}:${serverPort}`
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.20", "version": "1.2.9",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -57,7 +57,7 @@
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "0.4.0",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
+2 -2
View File
@@ -1,8 +1,8 @@
import { defineConfig, devices } from "@playwright/test" import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}` const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
-1
View File
@@ -145,7 +145,6 @@ try {
Object.assign(process.env, serverEnv) Object.assign(process.env, serverEnv)
process.env.AGENT = "1" process.env.AGENT = "1"
process.env.OPENCODE = "1" process.env.OPENCODE = "1"
process.env.OPENCODE_PID = String(process.pid)
const log = await import("../../opencode/src/util/log") const log = await import("../../opencode/src/util/log")
const install = await import("../../opencode/src/installation") const install = await import("../../opencode/src/installation")
+11 -10
View File
@@ -1,14 +1,16 @@
import "@/index.css" import "@/index.css"
import { File } from "@opencode-ai/ui/file" import { Code } from "@opencode-ai/ui/code"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
@@ -28,7 +30,6 @@ import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { Dynamic } from "solid-js/web"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
@@ -121,7 +122,9 @@ export function AppBaseProviders(props: ParentProps) {
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider> <DialogProvider>
<MarkedProviderWithNativeParser> <MarkedProviderWithNativeParser>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProviderWithNativeParser> </MarkedProviderWithNativeParser>
</DialogProvider> </DialogProvider>
</ErrorBoundary> </ErrorBoundary>
@@ -145,15 +148,13 @@ export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any> servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
}) { }) {
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
<Dynamic <Router
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
> >
<Route path="/" component={HomeRoute} /> <Route path="/" component={HomeRoute} />
@@ -161,7 +162,7 @@ export function AppInterface(props: {
<Route path="/" component={SessionIndexRoute} /> <Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/session/:id?" component={SessionRoute} />
</Route> </Route>
</Dynamic> </Router>
</GlobalSyncProvider> </GlobalSyncProvider>
</GlobalSDKProvider> </GlobalSDKProvider>
</ServerKey> </ServerKey>
@@ -4,6 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { List, type ListRef } from "@opencode-ai/ui/list" import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
@@ -446,7 +447,7 @@ export function DialogConnectProvider(props: { provider: string }) {
> >
<div class="flex flex-col gap-6 px-2.5 pb-3"> <div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center"> <div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" /> <ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong"> <div class="text-16-medium text-text-strong">
<Switch> <Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}> <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
@@ -2,7 +2,6 @@ import { createSignal } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
export type Highlight = { export type Highlight = {
@@ -17,7 +16,6 @@ export type Highlight = {
export function DialogReleaseNotes(props: { highlights: Highlight[] }) { export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const [index, setIndex] = createSignal(0) const [index, setIndex] = createSignal(0)
@@ -85,16 +83,16 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
<div class="flex flex-col items-start gap-3"> <div class="flex flex-col items-start gap-3">
{isLast() ? ( {isLast() ? (
<Button variant="primary" size="large" onClick={handleClose}> <Button variant="primary" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")} Get started
</Button> </Button>
) : ( ) : (
<Button variant="secondary" size="large" onClick={handleNext}> <Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")} Next
</Button> </Button>
)} )}
<Button variant="ghost" size="small" onClick={handleDisable}> <Button variant="ghost" size="small" onClick={handleDisable}>
{language.t("dialog.releaseNotes.action.hideFuture")} Don't show these in the future
</Button> </Button>
</div> </div>
@@ -130,7 +128,7 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
{feature()!.media!.type === "image" ? ( {feature()!.media!.type === "image" ? (
<img <img
src={feature()!.media!.src} src={feature()!.media!.src}
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")} alt={feature()!.media!.alt ?? feature()?.title ?? "Release preview"}
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
) : ( ) : (
@@ -8,7 +8,6 @@ import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
@@ -20,7 +19,6 @@ interface DialogSelectDirectoryProps {
type Row = { type Row = {
absolute: string absolute: string
search: string search: string
group: "recent" | "folders"
} }
function cleanInput(value: string) { function cleanInput(value: string) {
@@ -103,7 +101,7 @@ function displayPath(path: string, input: string, home: string) {
return tildeOf(full, home) || full return tildeOf(full, home) || full
} }
function toRow(absolute: string, home: string, group: Row["group"]): Row { function toRow(absolute: string, home: string): Row {
const full = trimTrailing(absolute) const full = trimTrailing(absolute)
const tilde = tildeOf(full, home) const tilde = tildeOf(full, home)
const withSlash = (value: string) => { const withSlash = (value: string) => {
@@ -115,16 +113,7 @@ function toRow(absolute: string, home: string, group: Row["group"]): Row {
const search = Array.from( const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n") ).join("\n")
return { absolute: full, search, group } return { absolute: full, search }
}
function uniqueRows(rows: Row[]) {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.absolute)) return false
seen.add(row.absolute)
return true
})
} }
function useDirectorySearch(args: { function useDirectorySearch(args: {
@@ -248,7 +237,6 @@ function useDirectorySearch(args: {
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync() const sync = useGlobalSync()
const sdk = useGlobalSDK() const sdk = useGlobalSDK()
const layout = useLayout()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -278,42 +266,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
start, start,
}) })
const recentProjects = createMemo(() => {
const projects = layout.projects.list()
const byProject = new Map<string, number>()
for (const project of projects) {
let at = 0
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
const sessions = sync.child(directory, { bootstrap: false })[0].session
for (const session of sessions) {
if (session.time.archived) continue
const updated = session.time.updated ?? session.time.created
if (updated > at) at = updated
}
}
byProject.set(project.worktree, at)
}
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
return {
...row,
search: `${row.search}\n${name}`,
}
})
})
const items = async (value: string) => { const items = async (value: string) => {
const results = await directories(value) const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) return results.map((absolute) => toRow(absolute, home()))
return uniqueRows([...recentProjects(), ...directoryRows])
} }
function resolve(absolute: string) { function resolve(absolute: string) {
@@ -330,14 +285,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
items={items} items={items}
key={(x) => x.absolute} key={(x) => x.absolute}
filterKeys={["search"]} filterKeys={["search"]}
groupBy={(item) => item.group}
sortGroupsBy={(a, b) => {
if (a.category === b.category) return 0
return a.category === "recent" ? -1 : 1
}}
groupHeader={(group) =>
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
}
ref={(r) => (list = r)} ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanInput(value))} onFilter={(value) => setFilter(cleanInput(value))}
onKeyEvent={(e, item) => { onKeyEvent={(e, item) => {
@@ -449,7 +449,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div> </div>
<Show when={item.updated}> <Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2"> <span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)} {getRelativeTime(new Date(item.updated!).toISOString())}
</span> </span>
</Show> </Show>
</div> </div>
@@ -1,6 +1,7 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
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 type { IconName } from "@opencode-ai/ui/icons/provider"
import { List, type ListRef } from "@opencode-ai/ui/list" import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
@@ -94,22 +95,11 @@ export const DialogSelectModelUnpaid: Component = () => {
> >
{(i) => ( {(i) => (
<div class="w-full flex items-center gap-x-3"> <div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} /> <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
<span>{i.name}</span> <span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === "opencode"}> <Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show> </Show>
<Show when={i.id === "opencode-go"}>
<>
<div class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
</div>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</>
</Show>
<Show when={i.id === "anthropic"}> <Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div> <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show> </Show>
@@ -5,12 +5,18 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogConnectProvider } from "./dialog-connect-provider"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
function icon(id: string): IconName {
if (iconNames.includes(id as IconName)) return id as IconName
return "synthetic"
}
export const DialogSelectProvider: Component = () => { export const DialogSelectProvider: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
@@ -23,7 +29,6 @@ export const DialogSelectProvider: Component = () => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note") if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note") if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
} }
return ( return (
@@ -63,11 +68,8 @@ export const DialogSelectProvider: Component = () => {
> >
{(i) => ( {(i) => (
<div class="px-1.25 w-full flex items-center gap-x-3"> <div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} /> <ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
<span>{i.name}</span> <span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === CUSTOM_ID}> <Show when={i.id === CUSTOM_ID}>
<Tag>{language.t("settings.providers.tag.custom")}</Tag> <Tag>{language.t("settings.providers.tag.custom")}</Tag>
</Show> </Show>
@@ -75,9 +77,6 @@ export const DialogSelectProvider: Component = () => {
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show> </Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show> <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
<Show when={i.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div> </div>
)} )}
</List> </List>
@@ -2,7 +2,6 @@ import { Button } from "@opencode-ai/ui/button"
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 { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
@@ -10,27 +9,32 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface ServerFormProps { interface AddRowProps {
value: string
placeholder: string
adding: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
interface EditRowProps {
value: string value: string
name: string
username: string
password: string
placeholder: string placeholder: string
busy: boolean busy: boolean
error: string error: string
status: boolean | undefined status: boolean | undefined
onChange: (value: string) => void onChange: (value: string) => void
onNameChange: (value: string) => void onKeyDown: (event: KeyboardEvent) => void
onUsernameChange: (value: string) => void onBlur: () => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onBack: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) { function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
@@ -79,86 +83,83 @@ function useServerPreview(fetcher: typeof fetch) {
return host.includes(".") || host.includes(":") return host.includes(".") || host.includes(":")
} }
const previewStatus = async ( const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined) setStatus(undefined)
if (!looksComplete(value)) return if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized } const result = await checkServerHealth({ url: normalized }, fetcher)
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
return { previewStatus } return { previewStatus }
} }
function ServerForm(props: ServerFormProps) { function AddRow(props: AddRowProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.onBack()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
props.onSubmit()
}
return ( return (
<div class="px-5"> <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3"> <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative"> <div
<TextField classList={{
type="text" "size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
label={language.t("dialog.server.add.url")} "bg-icon-success-base": props.status === true,
placeholder={props.placeholder} "bg-icon-critical-base": props.status === false,
value={props.value} "bg-border-weak-base": props.status === undefined,
autofocus }}
validationState={props.error ? "invalid" : "valid"} ref={(el) => {
error={props.error} // Position relative to input-wrapper
disabled={props.busy} requestAnimationFrame(() => {
onChange={props.onChange} const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
onKeyDown={keyDown} if (wrapper instanceof HTMLElement) {
/> wrapper.appendChild(el)
</div> }
})
}}
/>
<TextField <TextField
type="text" type="text"
label={language.t("dialog.server.add.name")} hideLabel
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={props.placeholder}
value={props.name} value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.adding}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
class="pl-7"
/>
</div>
</div>
)
}
function EditRow(props: EditRowProps) {
return (
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<div class="flex-1 min-w-0">
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy} disabled={props.busy}
onChange={props.onNameChange} onChange={props.onChange}
onKeyDown={keyDown} onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
/> />
<div class="grid grid-cols-2 gap-2 min-w-0">
<TextField
type="text"
label={language.t("dialog.server.add.username")}
placeholder="username"
value={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
/>
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder="password"
value={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
/>
</div>
</div> </div>
</div> </div>
) )
@@ -173,13 +174,11 @@ export function DialogSelectServer() {
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
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>, status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
name: "",
username: "",
password: "",
adding: false, adding: false,
error: "", error: "",
showForm: false, showForm: false,
@@ -188,9 +187,6 @@ export function DialogSelectServer() {
editServer: { editServer: {
id: undefined as string | undefined, id: undefined as string | undefined,
value: "", value: "",
name: "",
username: "",
password: "",
error: "", error: "",
busy: false, busy: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
@@ -200,32 +196,27 @@ export function DialogSelectServer() {
const resetAdd = () => { const resetAdd = () => {
setStore("addServer", { setStore("addServer", {
url: "", url: "",
name: "",
username: "",
password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined, status: undefined,
}) })
} }
const resetEdit = () => { const resetEdit = () => {
setStore("editServer", { setStore("editServer", {
id: undefined, id: undefined,
value: "", value: "",
name: "",
username: "",
password: "",
error: "", error: "",
status: undefined, status: undefined,
busy: false, busy: false,
}) })
} }
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.key const active = server.key
const newConn = server.add(next) const newConn = server.add(next)
if (!newConn) return if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original)) server.remove(ServerConnection.key(original))
@@ -280,8 +271,8 @@ export function DialogSelectServer() {
async function select(conn: ServerConnection.Any, persist?: boolean) { async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close() dialog.close()
if (persist && conn.type === "http") { if (persist) {
server.add(conn) server.add(conn.http.url)
navigate("/") navigate("/")
return return
} }
@@ -292,59 +283,21 @@ export function DialogSelectServer() {
const handleAddChange = (value: string) => { const handleAddChange = (value: string) => {
if (store.addServer.adding) return if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" }) setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) => void previewStatus(value, (next) => setStore("addServer", { status: next }))
setStore("addServer", { status: next }),
)
} }
const handleAddNameChange = (value: string) => { const scrollListToBottom = () => {
if (store.addServer.adding) return const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
setStore("addServer", { name: value, error: "" }) if (!scroll) return
} requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight
const handleAddUsernameChange = (value: string) => { })
if (store.addServer.adding) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
} }
const handleEditChange = (value: string) => { const handleEditChange = (value: string) => {
if (store.editServer.busy) return if (store.editServer.busy) return
setStore("editServer", { value, error: "" }) setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) => void previewStatus(value, (next) => setStore("editServer", { status: next }))
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
} }
async function handleAdd(value: string) { async function handleAdd(value: string) {
@@ -357,22 +310,16 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" }) setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = { const result = await checkServerHealth({ url: normalized }, fetcher)
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password
const result = await checkServerHealth(conn.http, fetcher)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") }) setStore("addServer", { error: language.t("dialog.server.add.error") })
return return
} }
resetAdd() resetAdd()
await select(conn, true) await select({ type: "http", http: { url: normalized } }, true)
} }
async function handleEdit(original: ServerConnection.Any, value: string) { async function handleEdit(original: ServerConnection.Any, value: string) {
@@ -383,114 +330,52 @@ export function DialogSelectServer() {
return return
} }
const name = store.editServer.name.trim() || undefined if (normalized === original.http.url) {
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit() resetEdit()
return return
} }
setStore("editServer", { busy: true, error: "" }) setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = { const result = await checkServerHealth({ url: normalized }, fetcher)
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") }) setStore("editServer", { error: language.t("dialog.server.add.error") })
return return
} }
if (normalized === original.http.url) {
server.add(conn) replaceServer(original, normalized)
} else {
replaceServer(original, conn)
}
resetEdit() resetEdit()
} }
const mode = createMemo<"list" | "add" | "edit">(() => { const handleAddKey = (event: KeyboardEvent) => {
if (store.editServer.id) return "edit" event.stopPropagation()
if (store.addServer.showForm) return "add" if (event.key !== "Enter" || event.isComposing) return
return "list" event.preventDefault()
}) handleAdd(store.addServer.url)
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
} }
const startAdd = () => { const blurAdd = () => {
resetEdit() if (!store.addServer.url.trim()) {
setStore("addServer", { resetAdd()
showForm: true,
url: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
})
}
const submitForm = () => {
if (mode() === "add") {
void handleAdd(store.addServer.url)
return return
} }
const original = editing() handleAdd(store.addServer.url)
if (!original) return
void handleEdit(original, store.editServer.value)
} }
const isFormMode = createMemo(() => mode() !== "list") const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
const isAddMode = createMemo(() => mode() === "add") event.stopPropagation()
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) if (event.key === "Escape") {
event.preventDefault()
const formTitle = createMemo(() => { resetEdit()
if (!isFormMode()) return language.t("dialog.server.title") return
return ( }
<div class="flex items-center gap-2 -ml-2"> if (event.key !== "Enter" || event.isComposing) return
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} /> event.preventDefault()
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span> handleEdit(original, store.editServer.value)
</div> }
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(url: ServerConnection.Key) { async function handleRemove(url: ServerConnection.Key) {
server.remove(url) server.remove(url)
@@ -500,29 +385,9 @@ export function DialogSelectServer() {
} }
return ( return (
<Dialog title={formTitle()}> <Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Show <div ref={(el) => (listRoot = el)}>
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<List <List
search={{ search={{
placeholder: language.t("dialog.server.search.placeholder"), placeholder: language.t("dialog.server.search.placeholder"),
@@ -535,110 +400,143 @@ export function DialogSelectServer() {
onSelect={(x) => { onSelect={(x) => {
if (x) select(x) if (x) select(x)
}} }}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true} divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
}
> >
{(i) => { {(i) => {
const key = ServerConnection.key(i) const key = ServerConnection.key(i)
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<div class="flex flex-col h-full items-start w-5"> <Show
<ServerHealthIndicator health={store.status[key]} /> when={store.editServer.id !== i.http.url}
</div> fallback={
<ServerRow <EditRow
conn={i} value={store.editServer.value}
dimmed={store.status[key]?.healthy === false} placeholder={language.t("dialog.server.add.placeholder")}
status={store.status[key]} busy={store.editServer.busy}
class="flex items-center gap-3 min-w-0 flex-1" error={store.editServer.error}
badge={ status={store.editServer.status}
<Show when={defaultUrl() === i.http.url}> onChange={handleEditChange}
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> onKeyDown={(event) => handleEditKey(event, i)}
{language.t("dialog.server.status.default")} onBlur={() => handleEdit(i, store.editServer.value)}
</span> />
</Show>
} }
showCredentials >
/> <ServerRow
<div class="flex items-center justify-center gap-4 pl-4"> conn={i}
<Show when={ServerConnection.key(current()) === key}> status={store.status[key]}
<Icon name="check" class="h-6" /> dimmed={store.status[key]?.healthy === false}
</Show> class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/>
</Show>
<Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<Show when={i.type === "http"}> <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={() => {
if (i.type !== "http") return setStore("editServer", {
startEdit(i) id: i.http.url,
}} value: i.http.url,
> error: "",
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> status: store.status[ServerConnection.key(i)]?.healthy,
</DropdownMenu.Item> })
<Show when={canDefault() && defaultUrl() !== i.http.url}> }}
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </DropdownMenu.Content>
<Show when={canDefault() && defaultUrl() === i.http.url}> </DropdownMenu.Portal>
<DropdownMenu.Item onSelect={() => setDefault(null)}> </DropdownMenu>
<DropdownMenu.ItemLabel> </Show>
{language.t("dialog.server.menu.defaultRemove")} </div>
</DropdownMenu.ItemLabel> </Show>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div> </div>
) )
}} }}
</List> </List>
</Show> </div>
<div class="px-5 pb-5"> <div class="px-5 pb-5">
<Show <Button
when={isFormMode()} variant="secondary"
fallback={ icon="plus-small"
<Button size="large"
variant="secondary" onClick={() => {
icon="plus-small" setStore("addServer", { showForm: true, url: "", error: "" })
size="large" scrollListToBottom()
onClick={startAdd} }}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
}
> >
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5"> {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
{formBusy() </Button>
? language.t("dialog.server.add.checking")
: isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</Show>
</div> </div>
</div> </div>
</Dialog> </Dialog>
+121 -54
View File
@@ -3,6 +3,7 @@ import { encodeFilePath } from "@/context/file/path"
import { Collapsible } from "@opencode-ai/ui/collapsible" import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { import {
createEffect, createEffect,
createMemo, createMemo,
@@ -191,6 +192,59 @@ const FileTreeNode = (
) )
} }
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
if (!props.enabled) return props.children
const parts = props.node.path.split("/")
const leaf = parts[parts.length - 1] ?? props.node.path
const head = parts.slice(0, -1).join("/")
const prefix = head ? `${head}/` : ""
const label =
props.kind === "add"
? "Additions"
: props.kind === "del"
? "Deletions"
: props.kind === "mix"
? "Modifications"
: undefined
return (
<Tooltip
openDelay={2000}
placement="bottom-start"
class="w-full"
contentStyle={{ "max-width": "480px", width: "fit-content" }}
value={
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
<span
class="min-w-0 truncate text-text-invert-base"
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
>
{prefix}
</span>
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
<Show when={label}>
{(text) => (
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">{text()}</span>
</>
)}
</Show>
<Show when={props.node.type === "directory" && props.node.ignored}>
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">Ignored</span>
</>
</Show>
</div>
}
>
{props.children}
</Tooltip>
)
}
export default function FileTree(props: { export default function FileTree(props: {
path: string path: string
class?: string class?: string
@@ -201,6 +255,7 @@ export default function FileTree(props: {
modified?: readonly string[] modified?: readonly string[]
kinds?: ReadonlyMap<string, Kind> kinds?: ReadonlyMap<string, Kind>
draggable?: boolean draggable?: boolean
tooltip?: boolean
onFileClick?: (file: FileNode) => void onFileClick?: (file: FileNode) => void
_filter?: Filter _filter?: Filter
@@ -212,6 +267,7 @@ export default function FileTree(props: {
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 key = (p: string) => const key = (p: string) =>
file file
@@ -325,6 +381,12 @@ export default function FileTree(props: {
), ),
) )
createEffect(() => {
const dir = file.tree.state(props.path)
if (!shouldListExpanded({ level, dir })) return
void file.tree.list(props.path)
})
const nodes = createMemo(() => { const nodes = createMemo(() => {
const nodes = file.tree.children(props.path) const nodes = file.tree.children(props.path)
const current = filter() const current = filter()
@@ -405,19 +467,21 @@ export default function FileTree(props: {
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
> >
<Collapsible.Trigger> <Collapsible.Trigger>
<FileTreeNode <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
node={node} <FileTreeNode
level={level} node={node}
active={props.active} level={level}
nodeClass={props.nodeClass} active={props.active}
draggable={draggable()} nodeClass={props.nodeClass}
kinds={kinds()} draggable={draggable()}
marks={marks()} kinds={kinds()}
> marks={marks()}
<div class="size-4 flex items-center justify-center text-icon-weak"> >
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" /> <div class="size-4 flex items-center justify-center text-icon-weak">
</div> <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
</FileTreeNode> </div>
</FileTreeNode>
</FileTreeNodeTooltip>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content class="relative pt-0.5"> <Collapsible.Content class="relative pt-0.5">
<div <div
@@ -440,6 +504,7 @@ export default function FileTree(props: {
kinds={props.kinds} kinds={props.kinds}
active={props.active} active={props.active}
draggable={props.draggable} draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick} onFileClick={props.onFileClick}
_filter={filter()} _filter={filter()}
_marks={marks()} _marks={marks()}
@@ -452,51 +517,53 @@ export default function FileTree(props: {
</Collapsible> </Collapsible>
</Match> </Match>
<Match when={node.type === "file"}> <Match when={node.type === "file"}>
<FileTreeNode <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
node={node} <FileTreeNode
level={level} node={node}
active={props.active} level={level}
nodeClass={props.nodeClass} active={props.active}
draggable={draggable()} nodeClass={props.nodeClass}
kinds={kinds()} draggable={draggable()}
marks={marks()} kinds={kinds()}
as="button" marks={marks()}
type="button" as="button"
onClick={() => props.onFileClick?.(node)} type="button"
> onClick={() => props.onFileClick?.(node)}
<div class="w-4 shrink-0" /> >
<Switch> <div class="w-4 shrink-0" />
<Match when={node.ignored}> <Switch>
<FileIcon <Match when={node.ignored}>
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 <FileIcon
node={node} node={node}
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100" class="size-4 filetree-icon filetree-icon--mono"
/> style="color: var(--icon-weak-base)"
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
mono mono
/> />
</span> </Match>
</Match> <Match when={active()}>
</Switch> <FileIcon
</FileTreeNode> 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>
</FileTreeNodeTooltip>
</Match> </Match>
</Switch> </Switch>
) )
+92 -213
View File
@@ -1,10 +1,9 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, 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"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { useFile } from "@/context/file"
import { import {
ContentPart, ContentPart,
DEFAULT_PROMPT, DEFAULT_PROMPT,
@@ -24,6 +23,7 @@ import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
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"
@@ -43,9 +43,6 @@ import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
navigatePromptHistory, navigatePromptHistory,
prependHistoryEntry, prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryEntry,
type PromptHistoryStoredEntry,
promptLength, promptLength,
} from "./prompt-input/history" } from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit" import { createPromptSubmit } from "./prompt-input/submit"
@@ -92,8 +89,6 @@ const EXAMPLES = [
"prompt.example.25", "prompt.example.25",
] as const ] as const
const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => { export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -173,29 +168,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const focus = { file: item.path, id: item.commentID } const focus = { file: item.path, id: item.commentID }
comments.setActive(focus) comments.setActive(focus)
const queueCommentFocus = (attempts = 6) => {
const schedule = (left: number) => {
requestAnimationFrame(() => {
comments.setFocus({ ...focus })
if (left <= 0) return
requestAnimationFrame(() => {
const current = comments.focus()
if (!current) return
if (current.file !== focus.file || current.id !== focus.id) return
schedule(left - 1)
})
})
}
schedule(attempts)
}
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) { if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("changes") layout.fileTree.setTab("changes")
tabs().setActive("review") tabs().setActive("review")
queueCommentFocus() requestAnimationFrame(() => comments.setFocus(focus))
return return
} }
@@ -203,8 +181,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
layout.fileTree.setTab("all") layout.fileTree.setTab("all")
const tab = files.tab(item.path) const tab = files.tab(item.path)
tabs().open(tab) tabs().open(tab)
tabs().setActive(tab) files.load(item.path)
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) requestAnimationFrame(() => comments.setFocus(focus))
} }
const recent = createMemo(() => { const recent = createMemo(() => {
@@ -239,33 +217,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
popover: "at" | "slash" | null popover: "at" | "slash" | null
historyIndex: number historyIndex: number
savedPrompt: PromptHistoryEntry | null savedPrompt: Prompt | null
placeholder: number placeholder: number
draggingType: "image" | "@mention" | null draggingType: "image" | "@mention" | null
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
pendingAutoAccept: boolean
}>({ }>({
popover: null, popover: null,
historyIndex: -1, historyIndex: -1,
savedPrompt: null as PromptHistoryEntry | null, savedPrompt: null,
placeholder: Math.floor(Math.random() * EXAMPLES.length), placeholder: Math.floor(Math.random() * EXAMPLES.length),
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
pendingAutoAccept: false,
})
const buttonsSpring = useSpring(
() => (store.mode === "normal" ? 1 : 0),
{ visualDuration: 0.2, bounce: 0 },
)
const springFade = (t: number): Record<string, string> => ({
opacity: `${t}`,
transform: `scale(${0.95 + t * 0.05})`,
filter: `blur(${(1 - t) * 2}px)`,
"pointer-events": t > 0.5 ? "auto" : "none",
}) })
const commentCount = createMemo(() => { const commentCount = createMemo(() => {
@@ -290,7 +254,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [history, setHistory] = persisted( const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]), Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{ createStore<{
entries: PromptHistoryStoredEntry[] entries: Prompt[]
}>({ }>({
entries: [], entries: [],
}), }),
@@ -298,7 +262,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [shellHistory, setShellHistory] = persisted( const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{ createStore<{
entries: PromptHistoryStoredEntry[] entries: Prompt[]
}>({ }>({
entries: [], entries: [],
}), }),
@@ -316,72 +280,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}), }),
) )
createEffect( const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
on(sessionKey, () => {
setStore("pendingAutoAccept", false)
}),
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
if (item.type !== "file") return []
const comment = item.comment?.trim()
if (!comment) return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
(item.selection
? ({
start: item.selection.startLine,
end: item.selection.endLine,
} satisfies SelectedLineRange)
: undefined)
if (!nextSelection) return []
return [
{
id: item.commentID ?? item.key,
path: item.path,
selection: { ...nextSelection },
comment,
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
origin: item.commentOrigin,
preview: item.preview,
} satisfies PromptHistoryComment,
]
})
}
const applyHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
id: item.id,
file: item.path,
selection: { ...item.selection },
comment: item.comment,
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file" as const,
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
commentID: item.id,
commentOrigin: item.origin,
preview: item.preview,
})),
)
}
const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
const p = entry.prompt
const length = position === "start" ? 0 : promptLength(p) const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true) setStore("applyingHistory", true)
applyHistoryComments(entry.comments)
prompt.set(p, length) prompt.set(p, length)
requestAnimationFrame(() => { requestAnimationFrame(() => {
editorRef.focus() editorRef.focus()
@@ -612,6 +513,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setActive: setSlashActive, setActive: setSlashActive,
onInput: slashOnInput, onInput: slashOnInput,
onKeyDown: slashOnKeyDown, onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
} = useFilteredList<SlashCommand>({ } = useFilteredList<SlashCommand>({
items: slashCommands, items: slashCommands,
key: (x) => x?.id, key: (x) => x?.id,
@@ -668,6 +570,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
createEffect(
on(
() => sync.data.command,
() => slashRefetch(),
{ defer: true },
),
)
// Auto-scroll active command into view when navigating with keyboard // Auto-scroll active command into view when navigating with keyboard
createEffect(() => { createEffect(() => {
const activeId = slashActive() const activeId = slashActive()
@@ -726,9 +636,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let buffer = "" let buffer = ""
const flushText = () => { const flushText = () => {
let content = buffer const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
buffer = "" buffer = ""
if (!content) return if (!content) return
parts.push({ type: "text", content, start: position, end: position + content.length }) parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -806,12 +714,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawParts = parseFromDOM() const rawParts = parseFromDOM()
const images = imageAttachments() const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
const rawText = const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
rawParts.length === 1 && rawParts[0]?.type === "text" const trimmed = rawText.replace(/\u200B/g, "").trim()
? rawParts[0].content
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const hasNonText = rawParts.some((part) => part.type !== "text") const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0 const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
if (shouldReset) { if (shouldReset) {
closePopover() closePopover()
@@ -851,31 +757,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const addPart = (part: ContentPart) => { const addPart = (part: ContentPart) => {
if (part.type === "image") return false
const selection = window.getSelection() const selection = window.getSelection()
if (!selection) return false if (!selection || selection.rangeCount === 0) return
if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { const cursorPosition = getCursorPosition(editorRef)
editorRef.focus() const currentPrompt = prompt.current()
const cursor = prompt.cursor() ?? promptLength(prompt.current()) const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
setCursorPosition(editorRef, cursor) const textBeforeCursor = rawText.substring(0, cursorPosition)
} const atMatch = textBeforeCursor.match(/@(\S*)$/)
if (selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return false
if (part.type === "file" || part.type === "agent") { if (part.type === "file" || part.type === "agent") {
const cursorPosition = getCursorPosition(editorRef)
const rawText = prompt
.current()
.map((p) => ("content" in p ? p.content : ""))
.join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
const pill = createPill(part) const pill = createPill(part)
const gap = document.createTextNode(" ") const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) { if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length const start = atMatch.index ?? cursorPosition - atMatch[0].length
@@ -890,9 +784,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
range.collapse(true) range.collapse(true)
selection.removeAllRanges() selection.removeAllRanges()
selection.addRange(range) selection.addRange(range)
} } else if (part.type === "text") {
const range = selection.getRangeAt(0)
if (part.type === "text") {
const fragment = createTextFragment(part.content) const fragment = createTextFragment(part.content)
const last = fragment.lastChild const last = fragment.lastChild
range.deleteContents() range.deleteContents()
@@ -928,13 +821,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
handleInput() handleInput()
closePopover() closePopover()
return true
} }
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const currentHistory = mode === "shell" ? shellHistory : history const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) const next = prependHistoryEntry(currentHistory.entries, prompt)
if (next === currentHistory.entries) return if (next === currentHistory.entries) return
setCurrentHistory("entries", next) setCurrentHistory("entries", next)
} }
@@ -945,13 +837,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
entries: store.mode === "shell" ? shellHistory.entries : history.entries, entries: store.mode === "shell" ? shellHistory.entries : history.entries,
historyIndex: store.historyIndex, historyIndex: store.historyIndex,
currentPrompt: prompt.current(), currentPrompt: prompt.current(),
currentComments: historyComments(),
savedPrompt: store.savedPrompt, savedPrompt: store.savedPrompt,
}) })
if (!result.handled) return false if (!result.handled) return false
setStore("historyIndex", result.historyIndex) setStore("historyIndex", result.historyIndex)
setStore("savedPrompt", result.savedPrompt) setStore("savedPrompt", result.savedPrompt)
applyHistoryPrompt(result.entry, result.cursor) applyHistoryPrompt(result.prompt, result.cursor)
return true return true
} }
@@ -968,18 +859,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
}) })
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return store.pendingAutoAccept
return permission.isAutoAccepting(id, sdk.directory)
})
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
info, info,
imageAttachments, imageAttachments,
commentCount, commentCount,
autoAccept: () => accepting(),
mode: () => store.mode, mode: () => store.mode,
working, working,
editor: () => editorRef, editor: () => editorRef,
@@ -1144,6 +1027,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
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-0">
<PromptPopover <PromptPopover
@@ -1221,9 +1106,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-multiline="true" aria-multiline="true"
aria-label={placeholder()} aria-label={placeholder()}
contenteditable="true" contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocapitalize="off"
autocorrect={store.mode === "normal" ? "on" : "off"} autocorrect="off"
spellcheck={store.mode === "normal"} spellcheck={false}
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={() => setComposing(true)} onCompositionStart={() => setComposing(true)}
@@ -1263,8 +1148,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div <div
aria-hidden={store.mode !== "normal"} aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1" class="flex items-center gap-1 transition-all duration-200 ease-out"
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }} 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 <TooltipKeybind
placement="top" placement="top"
@@ -1276,7 +1164,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button" type="button"
variant="ghost" variant="ghost"
class="size-8 p-0" class="size-8 p-0"
style={springFade(buttonsSpring())}
onClick={pick} onClick={pick}
disabled={store.mode !== "normal"} disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1314,68 +1201,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="size-8" class="size-8"
style={springFade(buttonsSpring())}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div class="pointer-events-none absolute bottom-2 left-2"> <Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
<div class="pointer-events-auto"> <div class="pointer-events-none absolute bottom-2 left-2">
<TooltipKeybind <div class="pointer-events-auto">
placement="top" <TooltipKeybind
gutter={8} placement="top"
title={language.t( gutter={8}
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable", title={language.t("command.permissions.autoaccept.enable")}
)} keybind={command.keybind("permissions.autoaccept")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => {
if (!params.id) {
setStore("pendingAutoAccept", (value) => !value)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
"size-6 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
aria-label={
accepting()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={accepting()}
> >
<Icon <Button
name="chevron-double-right" data-action="prompt-permissions"
size="small" variant="ghost"
classList={{ "text-icon-success-base": accepting() }} onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
/> classList={{
</Button> "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
</TooltipKeybind> "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>
</div> </Show>
</div> </div>
</DockShellForm> </DockShellForm>
<Show when={store.mode === "normal" || store.mode === "shell"}> <Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top"> <DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative"> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<div <Show when={store.mode === "shell"}>
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0" <div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }} <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
> <div class="size-4 shrink-0" />
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> </div>
<div class="size-4 shrink-0" /> </Show>
</div> <Show when={store.mode === "normal"}>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
@@ -1389,7 +1268,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set} onSelect={local.agent.set}
class="capitalize max-w-[160px]" class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular" valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} triggerStyle={{ height: "28px" }}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
@@ -1407,12 +1286,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost" variant="ghost"
size="normal" size="normal"
class="min-w-0 max-w-[320px] text-13-regular group" class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px", ...springFade(buttonsSpring()) }} 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} id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
@@ -1436,13 +1315,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
size: "normal", size: "normal",
style: { height: "28px", ...springFade(buttonsSpring()) }, style: { height: "28px" },
class: "min-w-0 max-w-[320px] text-13-regular group", 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} id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
@@ -1468,11 +1347,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]" class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular" valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} triggerStyle={{ height: "28px" }}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </Show>
</div> </div>
<div class="shrink-0"> <div class="shrink-0">
<RadioGroup <RadioGroup
@@ -7,19 +7,6 @@ import { getCursorPosition } from "./editor-dom"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
@@ -27,7 +14,7 @@ type PromptAttachmentsInput = {
isDialogActive: () => boolean isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void focusEditor: () => void
addPart: (part: ContentPart) => boolean addPart: (part: ContentPart) => void
readClipboardImage?: () => Promise<File | null> readClipboardImage?: () => Promise<File | null>
} }
@@ -102,13 +89,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
if (!plainText) return if (!plainText) return
if (largePaste(plainText)) {
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
input.focusEditor()
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText) const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
if (inserted) return if (inserted) return
@@ -35,15 +35,6 @@ describe("buildRequestParts", () => {
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true) ).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
expect(
result.requestParts.some(
(part) =>
part.type === "text" &&
part.synthetic &&
part.metadata?.opencodeComment &&
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
),
).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length) expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
@@ -4,7 +4,6 @@ import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
@@ -42,6 +41,18 @@ const fileQuery = (selection: FileSelection | undefined) =>
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
const range =
start === undefined || end === undefined
? "this file"
: start === end
? `line ${start}`
: `lines ${start} through ${end}`
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
}
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") { if (part.type === "text") {
return { return {
@@ -142,15 +153,8 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
type: "text", type: "text",
text: formatCommentNote({ path: item.path, selection: item.selection, comment }), text: commentNote(item.path, item.selection, comment),
synthetic: true, synthetic: true,
metadata: createCommentMetadata({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
}),
} satisfies PromptRequestPart, } satisfies PromptRequestPart,
filePart, filePart,
] ]
@@ -24,28 +24,6 @@ describe("prompt-input editor dom", () => {
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
}) })
test("createTextFragment avoids break-node explosion for large multiline content", () => {
const content = Array.from({ length: 220 }, () => "line").join("\n")
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(1)
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
expect(container.textContent).toBe(content)
})
test("createTextFragment keeps terminal break in large multiline fallback", () => {
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => { test("length helpers treat breaks as one char and ignore zero-width chars", () => {
const container = document.createElement("div") const container = document.createElement("div")
container.appendChild(document.createTextNode("ab\u200B")) container.appendChild(document.createTextNode("ab\u200B"))
@@ -1,20 +1,5 @@
const MAX_BREAKS = 200
export function createTextFragment(content: string): DocumentFragment { export function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment() const fragment = document.createDocumentFragment()
let breaks = 0
for (const char of content) {
if (char !== "\n") continue
breaks += 1
if (breaks > MAX_BREAKS) {
const tail = content.endsWith("\n")
const text = tail ? content.slice(0, -1) : content
if (text) fragment.appendChild(document.createTextNode(text))
if (tail) fragment.appendChild(document.createElement("br"))
return fragment
}
}
const segments = content.split("\n") const segments = content.split("\n")
segments.forEach((segment, index) => { segments.forEach((segment, index) => {
if (segment) { if (segment) {
@@ -3,42 +3,25 @@ import type { Prompt } from "@/context/prompt"
import { import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
clonePromptParts, clonePromptParts,
normalizePromptHistoryEntry,
navigatePromptHistory, navigatePromptHistory,
prependHistoryEntry, prependHistoryEntry,
promptLength, promptLength,
type PromptHistoryComment,
} from "./history" } from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
const comment = (id: string, value = "note"): PromptHistoryComment => ({
id,
path: "src/a.ts",
selection: { start: 2, end: 4 },
comment: value,
time: 1,
origin: "review",
preview: "const a = 1",
})
describe("prompt-input history", () => { describe("prompt-input history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => { test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT) const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([]) expect(first).toEqual([])
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
expect(commentsOnly).toHaveLength(1)
const withOne = prependHistoryEntry([], text("hello")) const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1) expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello")) const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne) expect(deduped).toBe(withOne)
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
expect(dedupedComments).toBe(commentsOnly)
}) })
test("navigatePromptHistory restores saved prompt when moving down from newest", () => { test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
@@ -48,57 +31,24 @@ describe("prompt-input history", () => {
entries, entries,
historyIndex: -1, historyIndex: -1,
currentPrompt: text("draft"), currentPrompt: text("draft"),
currentComments: [comment("draft")],
savedPrompt: null, savedPrompt: null,
}) })
expect(up.handled).toBe(true) expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled") if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0) expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start") expect(up.cursor).toBe("start")
expect(up.entry.comments).toEqual([])
const down = navigatePromptHistory({ const down = navigatePromptHistory({
direction: "down", direction: "down",
entries, entries,
historyIndex: up.historyIndex, historyIndex: up.historyIndex,
currentPrompt: text("ignored"), currentPrompt: text("ignored"),
currentComments: [],
savedPrompt: up.savedPrompt, savedPrompt: up.savedPrompt,
}) })
expect(down.handled).toBe(true) expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled") if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1) expect(down.historyIndex).toBe(-1)
expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft") expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
expect(down.entry.comments).toEqual([comment("draft")])
})
test("navigatePromptHistory keeps entry comments when moving through history", () => {
const entries = [
{
prompt: text("with comment"),
comments: [comment("c1")],
},
]
const up = navigatePromptHistory({
direction: "up",
entries,
historyIndex: -1,
currentPrompt: text("draft"),
currentComments: [],
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
expect(up.entry.comments).toEqual([comment("c1")])
})
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
const entry = normalizePromptHistoryEntry(text("legacy"))
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
expect(entry.comments).toEqual([])
}) })
test("helpers clone prompt and count text content length", () => { test("helpers clone prompt and count text content length", () => {
@@ -1,27 +1,9 @@
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import type { SelectedLineRange } from "@/context/file"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100 export const MAX_HISTORY = 100
export type PromptHistoryComment = {
id: string
path: string
selection: SelectedLineRange
comment: string
time: number
origin?: "review" | "file"
preview?: string
}
export type PromptHistoryEntry = {
prompt: Prompt
comments: PromptHistoryComment[]
}
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) { export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length)) const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0 const atStart = position === 0
@@ -43,82 +25,29 @@ export function clonePromptParts(prompt: Prompt): Prompt {
}) })
} }
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
return {
start: selection.start,
end: selection.end,
...(selection.side ? { side: selection.side } : {}),
...(selection.endSide ? { endSide: selection.endSide } : {}),
}
}
export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
return comments.map((comment) => ({
...comment,
selection: cloneSelection(comment.selection),
}))
}
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
if (Array.isArray(entry)) {
return {
prompt: clonePromptParts(entry),
comments: [],
}
}
return {
prompt: clonePromptParts(entry.prompt),
comments: clonePromptHistoryComments(entry.comments),
}
}
export function promptLength(prompt: Prompt) { export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
} }
export function prependHistoryEntry( export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
comments: PromptHistoryComment[] = [],
max = MAX_HISTORY,
) {
const text = prompt const text = prompt
.map((part) => ("content" in part ? part.content : "")) .map((part) => ("content" in part ? part.content : ""))
.join("") .join("")
.trim() .trim()
const hasImages = prompt.some((part) => part.type === "image") const hasImages = prompt.some((part) => part.type === "image")
const hasComments = comments.some((comment) => !!comment.comment.trim()) if (!text && !hasImages) return entries
if (!text && !hasImages && !hasComments) return entries
const entry = { const entry = clonePromptParts(prompt)
prompt: clonePromptParts(prompt),
comments: clonePromptHistoryComments(comments),
} satisfies PromptHistoryEntry
const last = entries[0] const last = entries[0]
if (last && isPromptEqual(last, entry)) return entries if (last && isPromptEqual(last, entry)) return entries
return [entry, ...entries].slice(0, max) return [entry, ...entries].slice(0, max)
} }
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) { function isPromptEqual(promptA: Prompt, promptB: Prompt) {
return ( if (promptA.length !== promptB.length) return false
commentA.path === commentB.path && for (let i = 0; i < promptA.length; i++) {
commentA.comment === commentB.comment && const partA = promptA[i]
commentA.origin === commentB.origin && const partB = promptB[i]
commentA.preview === commentB.preview &&
commentA.selection.start === commentB.selection.start &&
commentA.selection.end === commentB.selection.end &&
commentA.selection.side === commentB.selection.side &&
commentA.selection.endSide === commentB.selection.endSide
)
}
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
const entryA = normalizePromptHistoryEntry(promptA)
const entryB = normalizePromptHistoryEntry(promptB)
if (entryA.prompt.length !== entryB.prompt.length) return false
for (let i = 0; i < entryA.prompt.length; i++) {
const partA = entryA.prompt[i]
const partB = entryB.prompt[i]
if (partA.type !== partB.type) return false if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
if (partA.type === "file") { if (partA.type === "file") {
@@ -138,35 +67,28 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
} }
if (entryA.comments.length !== entryB.comments.length) return false
for (let i = 0; i < entryA.comments.length; i++) {
const commentA = entryA.comments[i]
const commentB = entryB.comments[i]
if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
}
return true return true
} }
type HistoryNavInput = { type HistoryNavInput = {
direction: "up" | "down" direction: "up" | "down"
entries: PromptHistoryStoredEntry[] entries: Prompt[]
historyIndex: number historyIndex: number
currentPrompt: Prompt currentPrompt: Prompt
currentComments: PromptHistoryComment[] savedPrompt: Prompt | null
savedPrompt: PromptHistoryEntry | null
} }
type HistoryNavResult = type HistoryNavResult =
| { | {
handled: false handled: false
historyIndex: number historyIndex: number
savedPrompt: PromptHistoryEntry | null savedPrompt: Prompt | null
} }
| { | {
handled: true handled: true
historyIndex: number historyIndex: number
savedPrompt: PromptHistoryEntry | null savedPrompt: Prompt | null
entry: PromptHistoryEntry prompt: Prompt
cursor: "start" | "end" cursor: "start" | "end"
} }
@@ -181,27 +103,22 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
} }
if (input.historyIndex === -1) { if (input.historyIndex === -1) {
const entry = normalizePromptHistoryEntry(input.entries[0])
return { return {
handled: true, handled: true,
historyIndex: 0, historyIndex: 0,
savedPrompt: { savedPrompt: clonePromptParts(input.currentPrompt),
prompt: clonePromptParts(input.currentPrompt), prompt: input.entries[0],
comments: clonePromptHistoryComments(input.currentComments),
},
entry,
cursor: "start", cursor: "start",
} }
} }
if (input.historyIndex < input.entries.length - 1) { if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1 const next = input.historyIndex + 1
const entry = normalizePromptHistoryEntry(input.entries[next])
return { return {
handled: true, handled: true,
historyIndex: next, historyIndex: next,
savedPrompt: input.savedPrompt, savedPrompt: input.savedPrompt,
entry, prompt: input.entries[next],
cursor: "start", cursor: "start",
} }
} }
@@ -215,12 +132,11 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
if (input.historyIndex > 0) { if (input.historyIndex > 0) {
const next = input.historyIndex - 1 const next = input.historyIndex - 1
const entry = normalizePromptHistoryEntry(input.entries[next])
return { return {
handled: true, handled: true,
historyIndex: next, historyIndex: next,
savedPrompt: input.savedPrompt, savedPrompt: input.savedPrompt,
entry, prompt: input.entries[next],
cursor: "end", cursor: "end",
} }
} }
@@ -231,7 +147,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true, handled: true,
historyIndex: -1, historyIndex: -1,
savedPrompt: null, savedPrompt: null,
entry: input.savedPrompt, prompt: input.savedPrompt,
cursor: "end", cursor: "end",
} }
} }
@@ -240,10 +156,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true, handled: true,
historyIndex: -1, historyIndex: -1,
savedPrompt: null, savedPrompt: null,
entry: { prompt: DEFAULT_PROMPT,
prompt: DEFAULT_PROMPT,
comments: [],
},
cursor: "end", cursor: "end",
} }
} }
@@ -5,7 +5,6 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = [] const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const sentShell: string[] = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
@@ -70,14 +69,6 @@ beforeAll(async () => {
}), }),
})) }))
mock.module("@/context/permission", () => ({
usePermission: () => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ sessionID, directory })
},
}),
}))
mock.module("@/context/prompt", () => ({ mock.module("@/context/prompt", () => ({
usePrompt: () => ({ usePrompt: () => ({
current: () => promptValue, current: () => promptValue,
@@ -154,7 +145,6 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdClients.length = 0 createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
enabledAutoAccept.length = 0
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
@@ -166,7 +156,6 @@ describe("prompt submit worktree selection", () => {
info: () => undefined, info: () => undefined,
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell", mode: () => "shell",
working: () => false, working: () => false,
editor: () => undefined, editor: () => undefined,
@@ -192,31 +181,4 @@ describe("prompt submit worktree selection", () => {
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
}) })
test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
})
}) })
@@ -1,15 +1,13 @@
import type { Message } from "@opencode-ai/sdk/v2/client" import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { errorMessage } from "@/pages/layout/helpers"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
@@ -17,7 +15,6 @@ 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 { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -30,7 +27,6 @@ type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined> info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]> imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number> commentCount: Accessor<number>
autoAccept: Accessor<boolean>
mode: Accessor<"normal" | "shell"> mode: Accessor<"normal" | "shell">
working: Accessor<boolean> working: Accessor<boolean>
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
@@ -60,13 +56,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const local = useLocal() const local = useLocal()
const permission = usePermission()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const abort = async () => { const abort = async () => {
const sessionID = params.id const sessionID = params.id
@@ -138,7 +140,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk.directory const projectDirectory = sdk.directory
const isNewSession = !params.id const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
@@ -152,7 +153,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"), title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: toastError(err), description: errorMessage(err),
}) })
return undefined return undefined
}) })
@@ -191,12 +192,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"), title: language.t("prompt.toast.sessionCreateFailed.title"),
description: toastError(err), description: errorMessage(err),
}) })
return undefined return undefined
}) })
if (session) { if (session) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
@@ -249,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.shellSendFailed.title"), title: language.t("prompt.toast.shellSendFailed.title"),
description: toastError(err), description: errorMessage(err),
}) })
restoreInput() restoreInput()
}) })
@@ -281,7 +281,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: errorMessage(err),
}) })
restoreInput() restoreInput()
}) })
@@ -327,14 +327,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
messageID, messageID,
}) })
batch(() => { removeCommentItems(commentItems)
removeCommentItems(commentItems) clearInput()
clearInput() addOptimisticMessage()
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
addOptimisticMessage()
})
const waitForWorktree = async () => { const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory) const worktree = WorktreeState.get(sessionDirectory)
@@ -411,7 +406,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
description: toastError(err), description: errorMessage(err),
}) })
removeOptimisticMessage() removeOptimisticMessage()
restoreCommentItems(commentItems) restoreCommentItems(commentItems)
@@ -1,6 +1,5 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { import {
children,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
@@ -10,7 +9,7 @@ import {
type ParentProps, type ParentProps,
Show, Show,
} from "solid-js" } from "solid-js"
import { type ServerConnection, serverName } from "@/context/server" 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 {
@@ -21,14 +20,13 @@ interface ServerRowProps extends ParentProps {
versionClass?: string versionClass?: string
dimmed?: boolean dimmed?: boolean
badge?: JSXElement badge?: JSXElement
showCredentials?: boolean
} }
export function ServerRow(props: ServerRowProps) { 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(() => serverName(props.conn)) const name = createMemo(() => serverDisplayName(props.conn))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -54,71 +52,35 @@ export function ServerRow(props: ServerRowProps) {
const tooltipValue = () => ( const tooltipValue = () => (
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span>{serverName(props.conn, true)}</span> <span>{name()}</span>
<Show when={props.status?.version}> <Show when={props.status?.version}>
<span class="text-text-invert-weak">v{props.status?.version}</span> <span class="text-text-invert-base">{props.status?.version}</span>
</Show> </Show>
</span> </span>
) )
const badge = children(() => props.badge)
return ( return (
<Tooltip <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
class="flex-1"
value={tooltipValue()}
placement="top-start"
inactive={!truncated() && !props.conn.displayName}
>
<div class={props.class} classList={{ "opacity-50": props.dimmed }}> <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
<div class="flex flex-col items-start"> <div
<div class="flex flex-row items-center gap-2"> classList={{
<span ref={nameRef} class={props.nameClass ?? "truncate"}> "size-1.5 rounded-full shrink-0": true,
{name()} "bg-icon-success-base": props.status?.healthy === true,
</span> "bg-icon-critical-base": props.status?.healthy === false,
<Show "bg-border-weak-base": props.status === undefined,
when={badge()} }}
fallback={ />
<Show when={props.status?.version}> <span ref={nameRef} class={props.nameClass ?? "truncate"}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}> {name()}
v{props.status?.version} </span>
</span> <Show when={props.status?.version}>
</Show> <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
} {props.status?.version}
> </span>
{(badge) => badge()} </Show>
</Show> {props.badge}
</div>
<Show when={props.showCredentials && props.conn.type === "http" && props.conn}>
{(conn) => (
<div class="flex flex-row gap-3">
<span>
{conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span>
) : (
<span class="text-text-weaker">no username</span>
)}
</span>
{conn().http.password && <span class="text-text-weak"></span>}
</div>
)}
</Show>
</div>
{props.children} {props.children}
</div> </div>
</Tooltip> </Tooltip>
) )
} }
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return (
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
)
}
@@ -39,7 +39,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.intl(), { new Intl.NumberFormat(language.locale(), {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}), }),
@@ -77,7 +77,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
{(ctx) => ( {(ctx) => (
<> <>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span> <span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span> <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -4,13 +4,13 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" 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, same } 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"
import { File } from "@opencode-ai/ui/file" import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown" import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
@@ -46,8 +46,7 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
}) })
return ( return (
<File <Code
mode="text"
file={file()} file={file()}
overflow="wrap" overflow="wrap"
class="select-text" class="select-text"
@@ -127,7 +126,7 @@ export function SessionContextTab() {
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.intl(), { new Intl.NumberFormat(language.locale(), {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}), }),
@@ -135,7 +134,7 @@ export function SessionContextTab() {
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(metrics().totalCost)
@@ -199,7 +198,7 @@ export function SessionContextTab() {
const stats = [ const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, { 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 },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
@@ -212,8 +211,8 @@ export function SessionContextTab() {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { 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(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) },
@@ -269,9 +268,9 @@ export function SessionContextTab() {
}) })
return ( return (
<ScrollView <div
class="@container h-full pb-10" class="@container h-full overflow-y-auto no-scrollbar pb-10"
viewportRef={(el) => { ref={(el) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
}} }}
@@ -306,7 +305,7 @@ export function SessionContextTab() {
<div class="flex items-center gap-1 text-11-regular text-text-weak"> <div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} /> <div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div> <div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div> <div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
</div> </div>
)} )}
</For> </For>
@@ -337,6 +336,6 @@ export function SessionContextTab() {
</Accordion> </Accordion>
</div> </div>
</div> </div>
</ScrollView> </div>
) )
} }

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