Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6f89d705 | ||
|
|
3871578db6 | ||
|
|
f380c757ff | ||
|
|
8fd4568071 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,59 +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": {
|
|
||||||
"*": "deny",
|
|
||||||
"packages/web/src/content/docs": "allow",
|
|
||||||
"packages/web/src/content/docs/*": "allow",
|
|
||||||
"packages/web/src/content/docs/*.mdx": "allow",
|
|
||||||
"packages/web/src/content/docs/*/*.mdx": "allow",
|
|
||||||
".opencode": "allow",
|
|
||||||
".opencode/agent": "allow",
|
|
||||||
".opencode/glossary": "allow",
|
|
||||||
".opencode/agent/translator.md": "allow",
|
|
||||||
".opencode/glossary/*.md": "allow"
|
|
||||||
},
|
|
||||||
"edit": {
|
|
||||||
"*": "deny",
|
|
||||||
"packages/web/src/content/docs/*/*.mdx": "allow"
|
|
||||||
},
|
|
||||||
"glob": {
|
|
||||||
"*": "deny",
|
|
||||||
"packages/web/src/content/docs*": "allow",
|
|
||||||
".opencode/glossary*": "allow"
|
|
||||||
},
|
|
||||||
"task": {
|
|
||||||
"*": "deny",
|
|
||||||
"translator": "allow"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agent": {
|
|
||||||
"translator": {
|
|
||||||
"permission": {
|
|
||||||
"*": "deny",
|
|
||||||
"read": {
|
|
||||||
"*": "deny",
|
|
||||||
".opencode/agent/translator.md": "allow",
|
|
||||||
".opencode/glossary/*.md": "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:
|
||||||
@@ -112,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'
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ jobs:
|
|||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
const login = pr.user.login;
|
const login = pr.user.login;
|
||||||
|
|
||||||
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
|
||||||
const cutoff = new Date('2026-02-19T00:00:00Z');
|
|
||||||
const prCreated = new Date(pr.created_at);
|
|
||||||
if (prCreated < cutoff) {
|
|
||||||
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if author is a team member or bot
|
// Check if author is a team member or bot
|
||||||
if (login === 'opencode-agent[bot]') return;
|
if (login === 'opencode-agent[bot]') return;
|
||||||
const { data: file } = await github.rest.repos.getContent({
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
@@ -108,11 +100,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 = `
|
||||||
@@ -165,14 +157,6 @@ jobs:
|
|||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
const login = pr.user.login;
|
const login = pr.user.login;
|
||||||
|
|
||||||
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
|
||||||
const cutoff = new Date('2026-02-19T00:00:00Z');
|
|
||||||
const prCreated = new Date(pr.created_at);
|
|
||||||
if (prCreated < cutoff) {
|
|
||||||
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if author is a team member or bot
|
// Check if author is a team member or bot
|
||||||
if (login === 'opencode-agent[bot]') return;
|
if (login === 'opencode-agent[bot]') return;
|
||||||
const { data: file } = await github.rest.repos.getContent({
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
@@ -189,7 +173,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 +209,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);
|
||||||
|
|||||||
@@ -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,8 +76,7 @@ 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:
|
||||||
@@ -206,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
|
||||||
@@ -220,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 }}
|
||||||
@@ -306,5 +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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -27,4 +27,3 @@ target
|
|||||||
opencode-dev
|
opencode-dev
|
||||||
logs/
|
logs/
|
||||||
*.bun-build
|
*.bun-build
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: bun-file-io
|
||||||
|
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use this when
|
||||||
|
|
||||||
|
- Editing file I/O or scans in `packages/opencode`
|
||||||
|
- Handling directory operations or external tools
|
||||||
|
|
||||||
|
## Bun file APIs (from Bun docs)
|
||||||
|
|
||||||
|
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
|
||||||
|
- Metadata: `file.size`, `file.type`, `file.name`.
|
||||||
|
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
|
||||||
|
- `Bun.file(...).delete()` deletes a file.
|
||||||
|
- `file.writer()` returns a FileSink for incremental writes.
|
||||||
|
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
|
||||||
|
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
|
||||||
|
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
|
||||||
|
|
||||||
|
## When to use node:fs
|
||||||
|
|
||||||
|
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
|
||||||
|
|
||||||
|
## Repo patterns
|
||||||
|
|
||||||
|
- Prefer Bun APIs over Node `fs` for file access.
|
||||||
|
- Check `Bun.file(...).exists()` before reading.
|
||||||
|
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
|
||||||
|
- Use `Bun.Glob` + `Array.fromAsync` for scans.
|
||||||
|
- Decode tool stderr with `Bun.readableStreamToText`.
|
||||||
|
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
||||||
|
|
||||||
|
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
|
||||||
|
Use Filesystem.exists(...) instead if path can be file or directory
|
||||||
|
|
||||||
|
## Quick checklist
|
||||||
|
|
||||||
|
- Use Bun APIs first.
|
||||||
|
- Use `path.join`/`path.resolve` for paths.
|
||||||
|
- Prefer promise `.catch(...)` over `try/catch` when possible.
|
||||||
@@ -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'")
|
||||||
|
|||||||
@@ -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.)
|
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
-140
@@ -1,140 +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>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[](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
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -33,9 +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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
-140
@@ -1,140 +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>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[](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
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -33,9 +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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -33,9 +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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
+10
-32
@@ -100,46 +100,26 @@ 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 ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
|
|
||||||
|
|
||||||
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_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
|
||||||
@@ -216,8 +196,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_BLACK_LIMITS,
|
ZEN_BLACK_LIMITS,
|
||||||
ZEN_LITE_PRICE,
|
|
||||||
ZEN_LITE_LIMITS,
|
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
...($dev
|
...($dev
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-dZoLhWe4smBsOF7WczMySLXSAB1YRO1vfhiOCL1rBf0=",
|
"x86_64-linux": "sha256-7y6gQyIxyrdp2DaG/0oOEpuL+1n9oa8arUn1CuDiDhA=",
|
||||||
"aarch64-linux": "sha256-J7nIz1xuVZEHun5WRZkYRySz29B0A8g5g0RRxnIWTYU=",
|
"aarch64-linux": "sha256-7dnHO2WqQZ9A8cG3EC8p7408YR9n2F5C6DG5rNWHqNY=",
|
||||||
"aarch64-darwin": "sha256-R2PuhX+EjUBuLE8MF0G0fcUwNaU+5n6V6uVeK89ulzw=",
|
"aarch64-darwin": "sha256-jxjhnVfE61RVOHaWvDO4mGLk6guQ8jHeXv/pbu5nbaE=",
|
||||||
"x86_64-darwin": "sha256-Bvzfz9TsTpYriZNLSLgpNcNb+BgtkgpjoWqdOtF2IBg="
|
"x86_64-darwin": "sha256-22yM4FEtVxGWRug6H0rKog86Q/cYE3QsADrRbLeJKVQ="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -4,7 +4,7 @@
|
|||||||
"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",
|
||||||
@@ -70,7 +70,6 @@
|
|||||||
"@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",
|
||||||
"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",
|
||||||
|
|||||||
+1
-158
@@ -225,7 +225,7 @@ 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 scroller = page.locator(".scroll-view__viewport").first()
|
const scroller = page.locator(".session-scroller").first()
|
||||||
await expect(scroller).toBeVisible()
|
await expect(scroller).toBeVisible()
|
||||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
@@ -332,163 +332,6 @@ export async function withSession<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedSystem = [
|
|
||||||
"You are seeding deterministic e2e UI state.",
|
|
||||||
"Follow the user's instruction exactly.",
|
|
||||||
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
|
|
||||||
"Do not call any extra tools.",
|
|
||||||
].join(" ")
|
|
||||||
|
|
||||||
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
|
|
||||||
const timeout = input.timeout ?? 30_000
|
|
||||||
const end = Date.now() + timeout
|
|
||||||
while (Date.now() < end) {
|
|
||||||
const value = await input.probe()
|
|
||||||
if (value !== undefined) return value
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seed = async <T>(input: {
|
|
||||||
sessionID: string
|
|
||||||
prompt: string
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
probe: () => Promise<T | undefined>
|
|
||||||
timeout?: number
|
|
||||||
attempts?: number
|
|
||||||
}) => {
|
|
||||||
for (let i = 0; i < (input.attempts ?? 2); i++) {
|
|
||||||
await input.sdk.session.promptAsync({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
agent: "build",
|
|
||||||
system: seedSystem,
|
|
||||||
parts: [{ type: "text", text: input.prompt }],
|
|
||||||
})
|
|
||||||
const value = await wait({ probe: input.probe, timeout: input.timeout })
|
|
||||||
if (value !== undefined) return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedSessionQuestion(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
questions: Array<{
|
|
||||||
header: string
|
|
||||||
question: string
|
|
||||||
options: Array<{ label: string; description: string }>
|
|
||||||
multiple?: boolean
|
|
||||||
custom?: boolean
|
|
||||||
}>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const first = input.questions[0]
|
|
||||||
if (!first) throw new Error("Question seed requires at least one question")
|
|
||||||
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one question tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
"After calling the tool, wait for the user response.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const list = await sdk.question.list().then((x) => x.data ?? [])
|
|
||||||
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding question request")
|
|
||||||
return { id: result.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedSessionPermission(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
permission: string
|
|
||||||
patterns: string[]
|
|
||||||
description?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one bash tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({
|
|
||||||
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
|
||||||
workdir: "/",
|
|
||||||
description: input.description ?? `seed ${input.permission} permission request`,
|
|
||||||
})}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
|
||||||
return list.find((item) => item.sessionID === input.sessionID)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding permission request")
|
|
||||||
return { id: result.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedSessionTodos(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
todos: Array<{ content: string; status: string; priority: string }>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one todowrite tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
const target = JSON.stringify(input.todos)
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
|
||||||
if (JSON.stringify(todos) !== target) return
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding todos")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
|
||||||
const [questions, permissions] = await Promise.all([
|
|
||||||
sdk.question.list().then((x) => x.data ?? []),
|
|
||||||
sdk.permission.list().then((x) => x.data ?? []),
|
|
||||||
])
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
...questions
|
|
||||||
.filter((item) => item.sessionID === sessionID)
|
|
||||||
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
|
|
||||||
...permissions
|
|
||||||
.filter((item) => item.sessionID === sessionID)
|
|
||||||
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
|
|
||||||
])
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openStatusPopover(page: Page) {
|
export async function openStatusPopover(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,60 +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()
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||||
defocus,
|
import { projectSwitchSelector } from "../selectors"
|
||||||
createTestProject,
|
import { dirSlug } from "../utils"
|
||||||
cleanupTestProject,
|
|
||||||
openSidebar,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
} from "../actions"
|
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
|
||||||
import { createSdk, dirSlug, sessionPath } from "../utils"
|
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
@@ -45,94 +33,3 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
|
|||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const otherSlug = dirSlug(other)
|
|
||||||
let rootDir: string | undefined
|
|
||||||
let workspaceDir: string | undefined
|
|
||||||
let sessionID: string | undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async ({ directory, slug }) => {
|
|
||||||
rootDir = directory
|
|
||||||
await defocus(page)
|
|
||||||
await openSidebar(page)
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const next = slugFromUrl(page.url())
|
|
||||||
if (!next) return ""
|
|
||||||
if (next === slug) return ""
|
|
||||||
return next
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
|
|
||||||
const workspaceSlug = slugFromUrl(page.url())
|
|
||||||
workspaceDir = base64Decode(workspaceSlug)
|
|
||||||
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
|
||||||
await expect(workspace).toBeVisible()
|
|
||||||
await workspace.hover()
|
|
||||||
|
|
||||||
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
|
||||||
await expect(newSession).toBeVisible()
|
|
||||||
await newSession.click({ force: true })
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
|
||||||
|
|
||||||
const created = await createSdk(workspaceDir)
|
|
||||||
.session.create()
|
|
||||||
.then((x) => x.data?.id)
|
|
||||||
if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
|
|
||||||
sessionID = created
|
|
||||||
|
|
||||||
await page.goto(sessionPath(workspaceDir, created))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
await expect(rootButton).toBeVisible()
|
|
||||||
await rootButton.click()
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
if (sessionID) {
|
|
||||||
const id = sessionID
|
|
||||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
|
||||||
await Promise.all(
|
|
||||||
dirs.map((directory) =>
|
|
||||||
createSdk(directory)
|
|
||||||
.session.delete({ sessionID: id })
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (workspaceDir) {
|
|
||||||
await cleanupTestProject(workspaceDir)
|
|
||||||
}
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
export const terminalSelector = '[data-component="terminal"]'
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
|
||||||
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
|
||||||
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
|
||||||
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
|
||||||
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
|
||||||
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
|
||||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
|
||||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
@@ -20,8 +10,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"]'
|
||||||
|
|
||||||
|
|||||||
@@ -1,414 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
|
||||||
import {
|
|
||||||
permissionDockSelector,
|
|
||||||
promptSelector,
|
|
||||||
questionDockSelector,
|
|
||||||
sessionComposerDockSelector,
|
|
||||||
sessionTodoDockSelector,
|
|
||||||
sessionTodoListSelector,
|
|
||||||
sessionTodoToggleButtonSelector,
|
|
||||||
} from "../selectors"
|
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
|
||||||
|
|
||||||
async function withDockSession<T>(
|
|
||||||
sdk: Sdk,
|
|
||||||
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")
|
|
||||||
try {
|
|
||||||
return await fn(session)
|
|
||||||
} finally {
|
|
||||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
|
||||||
try {
|
|
||||||
return await fn()
|
|
||||||
} finally {
|
|
||||||
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await expect(page.locator(promptSelector)).toBeFocused()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
await seedSessionQuestion(sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue now" },
|
|
||||||
{ label: "Stop", description: "Stop here" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_once",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
|
||||||
metadata: { 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 page.goto(page.url())
|
|
||||||
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 }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_reject",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
|
||||||
},
|
|
||||||
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 page.goto(page.url())
|
|
||||||
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 }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_always",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
|
||||||
metadata: { 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 withDockSeed(sdk, child.id, async () => {
|
|
||||||
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 expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
await seedSessionTodos(sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
todos: [
|
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
|
||||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await seedSessionTodos(sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
todos: [
|
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
await seedSessionQuestion(sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [{ label: "Continue", description: "Continue now" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
|
||||||
await page.keyboard.type("abc")
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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(".scroll-view__viewport").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)
|
||||||
|
|||||||
@@ -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,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}`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.15",
|
"version": "1.2.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -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,9 +1,11 @@
|
|||||||
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"
|
||||||
@@ -120,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -97,20 +97,9 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
<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 as IconName} />
|
<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>
|
||||||
|
|||||||
@@ -29,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 (
|
||||||
@@ -71,9 +70,6 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
<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={icon(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>
|
||||||
@@ -81,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,142 @@ 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)
|
|
||||||
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[ServerConnection.key(i)]}
|
||||||
<Icon name="check" class="h-6" />
|
dimmed={store.status[ServerConnection.key(i)]?.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={current() === i}>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -411,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
|
||||||
@@ -446,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()}
|
||||||
@@ -458,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo
|
|||||||
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,
|
||||||
@@ -20,7 +20,6 @@ import { useParams } from "@solidjs/router"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
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 type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
@@ -43,9 +42,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 +88,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 +167,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 +180,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,7 +216,7 @@ 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"
|
||||||
@@ -247,7 +224,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}>({
|
}>({
|
||||||
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",
|
||||||
@@ -276,7 +253,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: [],
|
||||||
}),
|
}),
|
||||||
@@ -284,7 +261,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: [],
|
||||||
}),
|
}),
|
||||||
@@ -302,66 +279,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const historyComments = () => {
|
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||||
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()
|
||||||
@@ -715,9 +635,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 })
|
||||||
@@ -795,12 +713,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()
|
||||||
@@ -840,31 +756,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
|
||||||
@@ -879,9 +783,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()
|
||||||
@@ -917,13 +820,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)
|
||||||
}
|
}
|
||||||
@@ -934,13 +836,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,11 +1027,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return false
|
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
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">
|
||||||
@@ -1149,11 +1045,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<DockShellForm
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
"group/prompt-input": true,
|
"group/prompt-input": true,
|
||||||
"focus-within:shadow-xs-border": true,
|
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
|
||||||
|
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
|
||||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
@@ -1310,50 +1207,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</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"
|
|
||||||
disabled={!params.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (!params.id) 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>
|
</form>
|
||||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||||
<DockTray attach="top">
|
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
|
||||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
<div class="px-2 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">
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<Show when={store.mode === "shell"}>
|
<Show when={store.mode === "shell"}>
|
||||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||||
@@ -1361,6 +1254,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<div class="size-4 shrink-0" />
|
<div class="size-4 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={store.mode === "normal"}>
|
<Show when={store.mode === "normal"}>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1460,7 +1354,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0" data-component="prompt-mode-toggle">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
options={["shell", "normal"] as const}
|
options={["shell", "normal"] as const}
|
||||||
current={store.mode}
|
current={store.mode}
|
||||||
@@ -1491,7 +1385,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,16 +73,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
|
||||||
globalSync.todo.set(sessionID, [])
|
|
||||||
const [, setStore] = globalSync.child(sdk.directory)
|
|
||||||
setStore("todo", sessionID, [])
|
|
||||||
|
|
||||||
const queued = pending.get(sessionID)
|
const queued = pending.get(sessionID)
|
||||||
if (queued) {
|
if (queued) {
|
||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
queued.cleanup()
|
queued.cleanup()
|
||||||
pending.delete(sessionID)
|
pending.delete(sessionID)
|
||||||
|
globalSync.todo.set(sessionID, undefined)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk.client.session
|
return sdk.client.session
|
||||||
@@ -90,6 +86,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
globalSync.todo.set(sessionID, undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreCommentItems = (items: CommentItem[]) => {
|
const restoreCommentItems = (items: CommentItem[]) => {
|
||||||
|
|||||||
+111
-179
@@ -3,13 +3,12 @@ import { createStore } from "solid-js/store"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
||||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
customOn: [] as boolean[],
|
customOn: [] as boolean[],
|
||||||
editing: false,
|
editing: false,
|
||||||
sending: false,
|
sending: false,
|
||||||
collapsed: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
@@ -33,7 +31,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
|
||||||
|
|
||||||
const summary = createMemo(() => {
|
const summary = createMemo(() => {
|
||||||
const n = Math.min(store.tab + 1, total())
|
const n = Math.min(store.tab + 1, total())
|
||||||
@@ -42,8 +39,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
|
|
||||||
const last = createMemo(() => store.tab >= total() - 1)
|
const last = createMemo(() => store.tab >= total() - 1)
|
||||||
|
|
||||||
const fold = () => setStore("collapsed", (value) => !value)
|
|
||||||
|
|
||||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||||
const prev = input().trim()
|
const prev = input().trim()
|
||||||
const next = value.trim()
|
const next = value.trim()
|
||||||
@@ -67,7 +62,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const measure = () => {
|
const measure = () => {
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
const scroller = document.querySelector(".scroll-view__viewport")
|
const scroller = document.querySelector(".session-scroller")
|
||||||
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
|
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
|
||||||
const top =
|
const top =
|
||||||
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
|
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
|
||||||
@@ -100,7 +95,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
window.addEventListener("resize", update)
|
window.addEventListener("resize", update)
|
||||||
|
|
||||||
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
||||||
const scroller = document.querySelector(".scroll-view__viewport")
|
const scroller = document.querySelector(".session-scroller")
|
||||||
const observer = new ResizeObserver(update)
|
const observer = new ResizeObserver(update)
|
||||||
if (dock instanceof HTMLElement) observer.observe(dock)
|
if (dock instanceof HTMLElement) observer.observe(dock)
|
||||||
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
||||||
@@ -120,7 +115,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const reply = async (answers: QuestionAnswer[]) => {
|
const reply = async (answers: QuestionAnswer[]) => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
|
||||||
props.onSubmit()
|
|
||||||
setStore("sending", true)
|
setStore("sending", true)
|
||||||
try {
|
try {
|
||||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||||
@@ -134,7 +128,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const reject = async () => {
|
const reject = async () => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
|
||||||
props.onSubmit()
|
|
||||||
setStore("sending", true)
|
setStore("sending", true)
|
||||||
try {
|
try {
|
||||||
await sdk.client.question.reject({ requestID: props.request.id })
|
await sdk.client.question.reject({ requestID: props.request.id })
|
||||||
@@ -244,21 +237,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
kind="question"
|
kind="question"
|
||||||
ref={(el) => (root = el)}
|
ref={(el) => (root = el)}
|
||||||
header={
|
header={
|
||||||
<div
|
<>
|
||||||
data-action="session-question-toggle"
|
|
||||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
|
||||||
onClick={fold}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return
|
|
||||||
event.preventDefault()
|
|
||||||
fold()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
<div data-slot="question-progress">
|
||||||
<For each={questions()}>
|
<For each={questions()}>
|
||||||
{(_, i) => (
|
{(_, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -270,38 +251,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||||
}
|
}
|
||||||
disabled={store.sending}
|
disabled={store.sending}
|
||||||
onMouseDown={(event) => {
|
onClick={() => jump(i())}
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
jump(i())
|
|
||||||
}}
|
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</>
|
||||||
<IconButton
|
|
||||||
data-action="session-question-toggle-button"
|
|
||||||
icon="chevron-down"
|
|
||||||
size="normal"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{ "rotate-180": store.collapsed }}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
fold()
|
|
||||||
}}
|
|
||||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -321,121 +277,56 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div data-slot="question-text">{question()?.question}</div>
|
||||||
data-slot="question-text"
|
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||||
class="cursor-default"
|
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||||
classList={{
|
|
||||||
"mb-6": store.collapsed && picked() === 0,
|
|
||||||
}}
|
|
||||||
role={store.collapsed ? "button" : undefined}
|
|
||||||
tabIndex={store.collapsed ? 0 : undefined}
|
|
||||||
onClick={fold}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (!store.collapsed) return
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return
|
|
||||||
event.preventDefault()
|
|
||||||
fold()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{question()?.question}
|
|
||||||
</div>
|
|
||||||
<Show when={store.collapsed && picked() > 0}>
|
|
||||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
|
||||||
{picked()} answer{picked() === 1 ? "" : "s"} selected
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
<div data-slot="question-options">
|
||||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
<For each={options()}>
|
||||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
{(opt, i) => {
|
||||||
</Show>
|
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||||
<div data-slot="question-options">
|
return (
|
||||||
<For each={options()}>
|
|
||||||
{(opt, i) => {
|
|
||||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
data-slot="question-option"
|
|
||||||
data-picked={picked()}
|
|
||||||
role={multi() ? "checkbox" : "radio"}
|
|
||||||
aria-checked={picked()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => selectOption(i())}
|
|
||||||
>
|
|
||||||
<span data-slot="question-option-check" aria-hidden="true">
|
|
||||||
<span
|
|
||||||
data-slot="question-option-box"
|
|
||||||
data-type={multi() ? "checkbox" : "radio"}
|
|
||||||
data-picked={picked()}
|
|
||||||
>
|
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
|
||||||
<Icon name="check-small" size="small" />
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span data-slot="question-option-main">
|
|
||||||
<span data-slot="option-label">{opt.label}</span>
|
|
||||||
<Show when={opt.description}>
|
|
||||||
<span data-slot="option-description">{opt.description}</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={store.editing}
|
|
||||||
fallback={
|
|
||||||
<button
|
<button
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-picked={picked()}
|
||||||
data-picked={on()}
|
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={picked()}
|
||||||
disabled={store.sending}
|
disabled={store.sending}
|
||||||
onClick={customOpen}
|
onClick={() => selectOption(i())}
|
||||||
>
|
>
|
||||||
<span
|
<span data-slot="question-option-check" aria-hidden="true">
|
||||||
data-slot="question-option-check"
|
<span
|
||||||
aria-hidden="true"
|
data-slot="question-option-box"
|
||||||
onClick={(e) => {
|
data-type={multi() ? "checkbox" : "radio"}
|
||||||
e.preventDefault()
|
data-picked={picked()}
|
||||||
e.stopPropagation()
|
>
|
||||||
customToggle()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
<Icon name="check-small" size="small" />
|
<Icon name="check-small" size="small" />
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-slot="question-option-main">
|
<span data-slot="question-option-main">
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
<span data-slot="option-label">{opt.label}</span>
|
||||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
<Show when={opt.description}>
|
||||||
|
<span data-slot="option-description">{opt.description}</span>
|
||||||
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
)
|
||||||
>
|
}}
|
||||||
<form
|
</For>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={store.editing}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-custom="true"
|
||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
onMouseDown={(e) => {
|
disabled={store.sending}
|
||||||
if (store.sending) {
|
onClick={customOpen}
|
||||||
e.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.target instanceof HTMLTextAreaElement) return
|
|
||||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
|
||||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
|
||||||
}}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
commitCustom()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-slot="question-option-check"
|
data-slot="question-option-check"
|
||||||
@@ -454,39 +345,80 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
</span>
|
</span>
|
||||||
<span data-slot="question-option-main">
|
<span data-slot="question-option-main">
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||||
<textarea
|
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||||
ref={(el) =>
|
|
||||||
setTimeout(() => {
|
|
||||||
el.focus()
|
|
||||||
el.style.height = "0px"
|
|
||||||
el.style.height = `${el.scrollHeight}px`
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
data-slot="question-custom-input"
|
|
||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
|
||||||
value={input()}
|
|
||||||
rows={1}
|
|
||||||
disabled={store.sending}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
|
||||||
e.preventDefault()
|
|
||||||
commitCustom()
|
|
||||||
}}
|
|
||||||
onInput={(e) => {
|
|
||||||
customUpdate(e.currentTarget.value)
|
|
||||||
e.currentTarget.style.height = "0px"
|
|
||||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</form>
|
</button>
|
||||||
</Show>
|
}
|
||||||
</div>
|
>
|
||||||
|
<form
|
||||||
|
data-slot="question-option"
|
||||||
|
data-custom="true"
|
||||||
|
data-picked={on()}
|
||||||
|
role={multi() ? "checkbox" : "radio"}
|
||||||
|
aria-checked={on()}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (store.sending) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.target instanceof HTMLTextAreaElement) return
|
||||||
|
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||||
|
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||||
|
}}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
commitCustom()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="question-option-check"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
customToggle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||||
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||||
|
<textarea
|
||||||
|
ref={(el) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
el.focus()
|
||||||
|
el.style.height = "0px"
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
data-slot="question-custom-input"
|
||||||
|
placeholder={language.t("ui.question.custom.placeholder")}
|
||||||
|
value={input()}
|
||||||
|
rows={1}
|
||||||
|
disabled={store.sending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
setStore("editing", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
commitCustom()
|
||||||
|
}}
|
||||||
|
onInput={(e) => {
|
||||||
|
customUpdate(e.currentTarget.value)
|
||||||
|
e.currentTarget.style.height = "0px"
|
||||||
|
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</DockPrompt>
|
</DockPrompt>
|
||||||
)
|
)
|
||||||
@@ -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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-8
@@ -1,6 +1,5 @@
|
|||||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -55,14 +54,13 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
|||||||
const preview = createMemo(() => active()?.content ?? "")
|
const preview = createMemo(() => active()?.content ?? "")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockTray
|
<div
|
||||||
data-component="session-todo-dock"
|
|
||||||
classList={{
|
classList={{
|
||||||
|
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
|
||||||
"h-[78px]": store.collapsed,
|
"h-[78px]": store.collapsed,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-action="session-todo-toggle"
|
|
||||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -83,11 +81,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
|||||||
</Show>
|
</Show>
|
||||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="session-todo-toggle-button"
|
|
||||||
icon="chevron-down"
|
icon="chevron-down"
|
||||||
size="normal"
|
size="normal"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
classList={{ "rotate-180": store.collapsed }}
|
classList={{ "rotate-180": !store.collapsed }}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -101,10 +98,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-slot="session-todo-list" hidden={store.collapsed}>
|
<div hidden={store.collapsed}>
|
||||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,9 +9,8 @@ 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"
|
||||||
@@ -47,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"
|
||||||
@@ -270,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()
|
||||||
}}
|
}}
|
||||||
@@ -338,6 +336,6 @@ export function SessionContextTab() {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
|
||||||
import { Popover } from "@opencode-ai/ui/popover"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useCommand } from "@/context/command"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { StatusPopover } from "../status-popover"
|
import { StatusPopover } from "../status-popover"
|
||||||
|
|
||||||
const OPEN_APPS = [
|
const OPEN_APPS = [
|
||||||
@@ -35,7 +35,6 @@ const OPEN_APPS = [
|
|||||||
"terminal",
|
"terminal",
|
||||||
"iterm2",
|
"iterm2",
|
||||||
"ghostty",
|
"ghostty",
|
||||||
"warp",
|
|
||||||
"xcode",
|
"xcode",
|
||||||
"android-studio",
|
"android-studio",
|
||||||
"powershell",
|
"powershell",
|
||||||
@@ -46,68 +45,32 @@ type OpenApp = (typeof OPEN_APPS)[number]
|
|||||||
type OS = "macos" | "windows" | "linux" | "unknown"
|
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||||
|
|
||||||
const MAC_APPS = [
|
const MAC_APPS = [
|
||||||
{
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
||||||
id: "vscode",
|
|
||||||
label: "VS Code",
|
|
||||||
icon: "vscode",
|
|
||||||
openWith: "Visual Studio Code",
|
|
||||||
},
|
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||||
{
|
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
||||||
id: "antigravity",
|
|
||||||
label: "Antigravity",
|
|
||||||
icon: "antigravity",
|
|
||||||
openWith: "Antigravity",
|
|
||||||
},
|
|
||||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||||
{ id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
|
|
||||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||||
{
|
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
||||||
id: "android-studio",
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
label: "Android Studio",
|
|
||||||
icon: "android-studio",
|
|
||||||
openWith: "Android Studio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sublime-text",
|
|
||||||
label: "Sublime Text",
|
|
||||||
icon: "sublime-text",
|
|
||||||
openWith: "Sublime Text",
|
|
||||||
},
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const WINDOWS_APPS = [
|
const WINDOWS_APPS = [
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||||
{
|
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
||||||
id: "powershell",
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
label: "PowerShell",
|
|
||||||
icon: "powershell",
|
|
||||||
openWith: "powershell",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sublime-text",
|
|
||||||
label: "Sublime Text",
|
|
||||||
icon: "sublime-text",
|
|
||||||
openWith: "Sublime Text",
|
|
||||||
},
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const LINUX_APPS = [
|
const LINUX_APPS = [
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||||
{
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
id: "sublime-text",
|
|
||||||
label: "Sublime Text",
|
|
||||||
icon: "sublime-text",
|
|
||||||
openWith: "Sublime Text",
|
|
||||||
},
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
||||||
@@ -250,9 +213,7 @@ export function SessionHeader() {
|
|||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
||||||
finder: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const apps = createMemo(() => {
|
const apps = createMemo(() => {
|
||||||
if (os() === "macos") return MAC_APPS
|
if (os() === "macos") return MAC_APPS
|
||||||
@@ -298,34 +259,18 @@ export function SessionHeader() {
|
|||||||
|
|
||||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||||
const [menu, setMenu] = createStore({ open: false })
|
const [menu, setMenu] = createStore({ open: false })
|
||||||
const [openRequest, setOpenRequest] = createStore({
|
|
||||||
app: undefined as OpenApp | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const value = prefs.app
|
|
||||||
if (options().some((o) => o.id === value)) return
|
|
||||||
setPrefs("app", options()[0]?.id ?? "finder")
|
|
||||||
})
|
|
||||||
|
|
||||||
const openDir = (app: OpenApp) => {
|
const openDir = (app: OpenApp) => {
|
||||||
if (opening() || !canOpen() || !platform.openPath) return
|
|
||||||
const directory = projectDirectory()
|
const directory = projectDirectory()
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
if (!canOpen()) return
|
||||||
|
|
||||||
const item = options().find((o) => o.id === app)
|
const item = options().find((o) => o.id === app)
|
||||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||||
setOpenRequest("app", app)
|
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
|
||||||
platform
|
|
||||||
.openPath(directory, openWith)
|
|
||||||
.catch((err: unknown) => showRequestError(language, err))
|
|
||||||
.finally(() => {
|
|
||||||
setOpenRequest("app", undefined)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyPath = () => {
|
const copyPath = () => {
|
||||||
@@ -370,9 +315,7 @@ export function SessionHeader() {
|
|||||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
||||||
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
||||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||||
{language.t("session.header.search.placeholder", {
|
{language.t("session.header.search.placeholder", { project: name() })}
|
||||||
project: name(),
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -414,23 +357,14 @@ export function SessionHeader() {
|
|||||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
|
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||||
classList={{
|
|
||||||
"bg-surface-raised-base-active": opening(),
|
|
||||||
}}
|
|
||||||
onClick={() => openDir(current().id)}
|
onClick={() => openDir(current().id)}
|
||||||
disabled={opening()}
|
|
||||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||||
>
|
>
|
||||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||||
<Show
|
<AppIcon id={current().icon} class="size-4" />
|
||||||
when={opening()}
|
|
||||||
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
|
|
||||||
>
|
|
||||||
<Spinner class="size-3.5 text-icon-base" />
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
|
<span class="text-12-regular text-text-strong">Open</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div class="self-stretch w-px bg-border-weak-base" />
|
<div class="self-stretch w-px bg-border-weak-base" />
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@@ -443,11 +377,7 @@ export function SessionHeader() {
|
|||||||
as={IconButton}
|
as={IconButton}
|
||||||
icon="chevron-down"
|
icon="chevron-down"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={opening()}
|
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover"
|
||||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-raised-base-active": opening(),
|
|
||||||
}}
|
|
||||||
aria-label={language.t("session.header.open.menu")}
|
aria-label={language.t("session.header.open.menu")}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
@@ -465,7 +395,6 @@ export function SessionHeader() {
|
|||||||
{(o) => (
|
{(o) => (
|
||||||
<DropdownMenu.RadioItem
|
<DropdownMenu.RadioItem
|
||||||
value={o.id}
|
value={o.id}
|
||||||
disabled={opening()}
|
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setMenu("open", false)
|
setMenu("open", false)
|
||||||
openDir(o.id)
|
openDir(o.id)
|
||||||
@@ -523,10 +452,7 @@ export function SessionHeader() {
|
|||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
class:
|
class:
|
||||||
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||||
classList: {
|
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
||||||
"rounded-r-none": share.shareUrl() !== undefined,
|
|
||||||
"border-r-0": share.shareUrl() !== undefined,
|
|
||||||
},
|
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
||||||
|
|||||||
@@ -13,15 +13,13 @@ import { useCommand } from "@/context/command"
|
|||||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-x-1.5 min-w-0">
|
<div class="flex items-center gap-x-1.5 min-w-0">
|
||||||
<Show
|
<FileIcon
|
||||||
when={!props.active}
|
node={{ path: props.path, type: "file" }}
|
||||||
fallback={<FileIcon node={{ path: props.path, type: "file" }} class="size-4 shrink-0" />}
|
classList={{
|
||||||
>
|
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
||||||
<span class="relative inline-flex size-4 shrink-0">
|
"grayscale-0": props.active,
|
||||||
<FileIcon node={{ path: props.path, type: "file" }} class="absolute inset-0 size-4 tab-fileicon-color" />
|
}}
|
||||||
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
/>
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -39,8 +37,8 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
return <FileVisual path={value} />
|
return <FileVisual path={value} />
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||||
<div class="relative">
|
<div class="relative h-full">
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={props.tab}
|
value={props.tab}
|
||||||
closeButton={
|
closeButton={
|
||||||
@@ -48,7 +46,6 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
title={language.t("common.closeTab")}
|
title={language.t("common.closeTab")}
|
||||||
keybind={command.keybind("tab.close")}
|
keybind={command.keybind("tab.close")}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
gutter={10}
|
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="close-small"
|
icon="close-small"
|
||||||
|
|||||||
@@ -20,17 +20,12 @@ let demoSoundState = {
|
|||||||
|
|
||||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||||
const stopDemoSound = () => {
|
const playDemoSound = (src: string) => {
|
||||||
if (demoSoundState.cleanup) {
|
if (demoSoundState.cleanup) {
|
||||||
demoSoundState.cleanup()
|
demoSoundState.cleanup()
|
||||||
}
|
}
|
||||||
clearTimeout(demoSoundState.timeout)
|
|
||||||
demoSoundState.cleanup = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const playDemoSound = (src: string | undefined) => {
|
clearTimeout(demoSoundState.timeout)
|
||||||
stopDemoSound()
|
|
||||||
if (!src) return
|
|
||||||
|
|
||||||
demoSoundState.timeout = setTimeout(() => {
|
demoSoundState.timeout = setTimeout(() => {
|
||||||
demoSoundState.cleanup = playSound(src)
|
demoSoundState.cleanup = playSound(src)
|
||||||
@@ -137,17 +132,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
] as const
|
] as const
|
||||||
const fontOptionsList = [...fontOptions]
|
const fontOptionsList = [...fontOptions]
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
const soundOptions = [...SOUND_OPTIONS]
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
|
||||||
|
|
||||||
const soundSelectProps = (
|
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
|
||||||
enabled: () => boolean,
|
|
||||||
current: () => string,
|
|
||||||
setEnabled: (value: boolean) => void,
|
|
||||||
set: (id: string) => void,
|
|
||||||
) => ({
|
|
||||||
options: soundOptions,
|
options: soundOptions,
|
||||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
current: soundOptions.find((o) => o.id === current()),
|
||||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
@@ -156,12 +145,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
},
|
},
|
||||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.id === "none") {
|
|
||||||
setEnabled(false)
|
|
||||||
stopDemoSound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnabled(true)
|
|
||||||
set(option.id)
|
set(option.id)
|
||||||
playDemoSound(option.src)
|
playDemoSound(option.src)
|
||||||
},
|
},
|
||||||
@@ -271,50 +254,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const FeedSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
|
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-reasoning-summaries">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showReasoningSummaries()}
|
|
||||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.shellToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.editToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const NotificationsSection = () => (
|
const NotificationsSection = () => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||||
@@ -368,45 +307,66 @@ export const SettingsGeneral: Component = () => {
|
|||||||
title={language.t("settings.general.sounds.agent.title")}
|
title={language.t("settings.general.sounds.agent.title")}
|
||||||
description={language.t("settings.general.sounds.agent.description")}
|
description={language.t("settings.general.sounds.agent.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<div class="flex items-center gap-2">
|
||||||
data-action="settings-sounds-agent"
|
<div data-action="settings-sounds-agent-enabled">
|
||||||
{...soundSelectProps(
|
<Switch
|
||||||
() => settings.sounds.agentEnabled(),
|
checked={settings.sounds.agentEnabled()}
|
||||||
() => settings.sounds.agent(),
|
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
|
||||||
(value) => settings.sounds.setAgentEnabled(value),
|
/>
|
||||||
(id) => settings.sounds.setAgent(id),
|
</div>
|
||||||
)}
|
<Select
|
||||||
/>
|
disabled={!settings.sounds.agentEnabled()}
|
||||||
|
data-action="settings-sounds-agent"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.agent(),
|
||||||
|
(id) => settings.sounds.setAgent(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.sounds.permissions.title")}
|
title={language.t("settings.general.sounds.permissions.title")}
|
||||||
description={language.t("settings.general.sounds.permissions.description")}
|
description={language.t("settings.general.sounds.permissions.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<div class="flex items-center gap-2">
|
||||||
data-action="settings-sounds-permissions"
|
<div data-action="settings-sounds-permissions-enabled">
|
||||||
{...soundSelectProps(
|
<Switch
|
||||||
() => settings.sounds.permissionsEnabled(),
|
checked={settings.sounds.permissionsEnabled()}
|
||||||
() => settings.sounds.permissions(),
|
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
|
||||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
/>
|
||||||
(id) => settings.sounds.setPermissions(id),
|
</div>
|
||||||
)}
|
<Select
|
||||||
/>
|
disabled={!settings.sounds.permissionsEnabled()}
|
||||||
|
data-action="settings-sounds-permissions"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.permissions(),
|
||||||
|
(id) => settings.sounds.setPermissions(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.sounds.errors.title")}
|
title={language.t("settings.general.sounds.errors.title")}
|
||||||
description={language.t("settings.general.sounds.errors.description")}
|
description={language.t("settings.general.sounds.errors.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<div class="flex items-center gap-2">
|
||||||
data-action="settings-sounds-errors"
|
<div data-action="settings-sounds-errors-enabled">
|
||||||
{...soundSelectProps(
|
<Switch
|
||||||
() => settings.sounds.errorsEnabled(),
|
checked={settings.sounds.errorsEnabled()}
|
||||||
() => settings.sounds.errors(),
|
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
|
||||||
(value) => settings.sounds.setErrorsEnabled(value),
|
/>
|
||||||
(id) => settings.sounds.setErrors(id),
|
</div>
|
||||||
)}
|
<Select
|
||||||
/>
|
disabled={!settings.sounds.errorsEnabled()}
|
||||||
|
data-action="settings-sounds-errors"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.errors(),
|
||||||
|
(id) => settings.sounds.setErrors(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -458,7 +418,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,8 +427,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<div class="flex flex-col gap-8 w-full">
|
<div class="flex flex-col gap-8 w-full">
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|
||||||
<FeedSection />
|
|
||||||
|
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
|
|
||||||
<SoundsSection />
|
<SoundsSection />
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const SettingsModels: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const SettingsPermissions: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +162,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
when={canDisconnect(item)}
|
when={canDisconnect(item)}
|
||||||
fallback={
|
fallback={
|
||||||
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
|
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
|
||||||
{language.t("settings.providers.connected.environmentDescription")}
|
Connected from your environment variables
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -187,22 +187,9 @@ export const SettingsProviders: Component = () => {
|
|||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||||
<Show when={item.id === "opencode"}>
|
|
||||||
<span class="text-14-regular text-text-weak">
|
|
||||||
{language.t("dialog.provider.opencode.tagline")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "opencode"}>
|
<Show when={item.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.id === "opencode-go"}>
|
|
||||||
<>
|
|
||||||
<span class="text-14-regular text-text-weak">
|
|
||||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={note(item.id)}>
|
<Show when={note(item.id)}>
|
||||||
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
||||||
@@ -229,12 +216,10 @@ export const SettingsProviders: Component = () => {
|
|||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
<span class="text-14-medium text-text-strong">Custom provider</span>
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
<span class="text-12-regular text-text-weak pl-8">Add an OpenAI-compatible provider by base URL.</span>
|
||||||
{language.t("settings.providers.custom.description")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { 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 { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -276,11 +276,10 @@ export function StatusPopover() {
|
|||||||
navigate("/")
|
navigate("/")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerHealthIndicator health={health[key]} />
|
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={s}
|
conn={s}
|
||||||
dimmed={isBlocked()}
|
|
||||||
status={health[key]}
|
status={health[key]}
|
||||||
|
dimmed={isBlocked()}
|
||||||
class="flex items-center gap-2 w-full min-w-0"
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
|
|||||||
@@ -320,6 +320,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const mod = loaded.mod
|
const mod = loaded.mod
|
||||||
const g = loaded.ghostty
|
const g = loaded.ghostty
|
||||||
|
|
||||||
|
const once = { value: false }
|
||||||
|
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
restore &&
|
restore &&
|
||||||
@@ -414,28 +416,20 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||||
}
|
}
|
||||||
|
|
||||||
const write = (data: string) =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
if (!output) {
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output.push(data)
|
|
||||||
output.flush(resolve)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (restore && restoreSize) {
|
if (restore && restoreSize) {
|
||||||
await write(restore)
|
t.write(restore, () => {
|
||||||
fit.fit()
|
fit.fit()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||||
startResize()
|
startResize()
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
fit.fit()
|
fit.fit()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
if (restore) {
|
if (restore) {
|
||||||
await write(restore)
|
t.write(restore, () => {
|
||||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
startResize()
|
startResize()
|
||||||
}
|
}
|
||||||
@@ -444,32 +438,38 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
// console.log("Scroll position:", ydisp)
|
// console.log("Scroll position:", ydisp)
|
||||||
// })
|
// })
|
||||||
|
|
||||||
const once = { value: false }
|
|
||||||
let closing = false
|
|
||||||
|
|
||||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||||
url.searchParams.set("directory", sdk.directory)
|
url.searchParams.set("directory", sdk.directory)
|
||||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||||
url.username = server.current?.http.username ?? ""
|
url.username = server.current?.http.username ?? ""
|
||||||
url.password = server.current?.http.password ?? ""
|
url.password = server.current?.http.password ?? ""
|
||||||
|
|
||||||
const socket = new WebSocket(url)
|
const socket = new WebSocket(url)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
ws = socket
|
ws = socket
|
||||||
|
cleanups.push(() => {
|
||||||
|
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
||||||
|
})
|
||||||
|
if (disposed) {
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
}
|
}
|
||||||
socket.addEventListener("open", handleOpen)
|
socket.addEventListener("open", handleOpen)
|
||||||
|
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
||||||
|
|
||||||
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
||||||
|
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (closing) return
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
||||||
const bytes = new Uint8Array(event.data)
|
const bytes = new Uint8Array(event.data)
|
||||||
if (bytes[0] !== 0) return
|
if (bytes[0] !== 0) return
|
||||||
const json = decoder.decode(bytes.subarray(1))
|
const json = decoder.decode(bytes.subarray(1))
|
||||||
@@ -491,20 +491,20 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
cursor += data.length
|
cursor += data.length
|
||||||
}
|
}
|
||||||
socket.addEventListener("message", handleMessage)
|
socket.addEventListener("message", handleMessage)
|
||||||
|
cleanups.push(() => socket.removeEventListener("message", handleMessage))
|
||||||
|
|
||||||
const handleError = (error: Event) => {
|
const handleError = (error: Event) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (closing) return
|
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
once.value = true
|
once.value = true
|
||||||
console.error("WebSocket error:", error)
|
console.error("WebSocket error:", error)
|
||||||
local.onConnectError?.(error)
|
local.onConnectError?.(error)
|
||||||
}
|
}
|
||||||
socket.addEventListener("error", handleError)
|
socket.addEventListener("error", handleError)
|
||||||
|
cleanups.push(() => socket.removeEventListener("error", handleError))
|
||||||
|
|
||||||
const handleClose = (event: CloseEvent) => {
|
const handleClose = (event: CloseEvent) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (closing) return
|
|
||||||
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
||||||
// For other codes (network issues, server restart), trigger error handler
|
// For other codes (network issues, server restart), trigger error handler
|
||||||
if (event.code !== 1000) {
|
if (event.code !== 1000) {
|
||||||
@@ -514,15 +514,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
socket.addEventListener("close", handleClose)
|
socket.addEventListener("close", handleClose)
|
||||||
|
cleanups.push(() => socket.removeEventListener("close", handleClose))
|
||||||
cleanups.push(() => {
|
|
||||||
closing = true
|
|
||||||
socket.removeEventListener("open", handleOpen)
|
|
||||||
socket.removeEventListener("message", handleMessage)
|
|
||||||
socket.removeEventListener("error", handleError)
|
|
||||||
socket.removeEventListener("close", handleClose)
|
|
||||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void run().catch((err) => {
|
void run().catch((err) => {
|
||||||
@@ -540,7 +532,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
disposed = true
|
disposed = true
|
||||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
|
||||||
|
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user