Compare commits

...

38 Commits

Author SHA1 Message Date
Aiden Cline
1e2ac94a91 test 2026-01-08 23:29:26 -06:00
Aiden Cline
52d7475dbf fix: add back hook and remove dead code 2026-01-08 18:10:26 -06:00
Github Action
49d9f99924 Update Nix flake.lock and hashes 2026-01-08 22:56:55 +00:00
Sebastian Herrlinger
1f9e195cd8 stop esc propagation from dialogs 2026-01-08 23:53:00 +01:00
Sebastian Herrlinger
539d6baa8c upgrade opentui to v0.1.70 2026-01-08 23:52:36 +01:00
Aleksandr Bagatka
f6fc693c1f fix(ui): use full file path for fuzzy matching in autocomplete (#6705)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-08 16:43:05 -06:00
GitHub Action
50d8396c9a chore: generate 2026-01-08 22:33:10 +00:00
Dax
22dd70b75b feat(question): support multi-select questions (#7386) 2026-01-08 17:32:21 -05:00
GitHub Action
b4f8de0c0a chore: generate 2026-01-08 22:03:23 +00:00
Marc Espin
768e0553bd fix(docs): Document cargofmt (#7383) 2026-01-08 16:00:03 -06:00
opencode
f3e79235fb release: v1.1.7 2026-01-08 20:37:24 +00:00
ryanwyler
eacf3ad361 fix(tui): restore showDetails check removed in Permission rework (#7285) 2026-01-08 14:30:41 -06:00
Mani Sundararajan
72062d22a0 fix: cleanly restore terminal state on fatal error exit (#7359) 2026-01-08 14:22:24 -06:00
Aiden Cline
9f90f0e8ed ci: update fork_repo var 2026-01-08 14:19:44 -06:00
Aiden Cline
c74bc323b6 ci: tweak enforcement of titles 2026-01-08 14:12:38 -06:00
zerone0x
c7b825a42a fix(app): show custom models without valid release_date in web UI mod… (#7349) 2026-01-08 14:06:18 -06:00
Dax Raad
b1a613b3b9 ci: add retry logic to desktop build to handle intermittent failures 2026-01-08 14:57:55 -05:00
Noam Bressler
958f1edfef fix: Add kind, title, rawInput to ACP tool_call_update events (#7368)
Co-authored-by: noam-v <noam@bespo.ai>
2026-01-08 13:56:16 -06:00
Dax
2bb299d741 tui: improve responsive layout by hiding header/footer when sidebar is visible (#7365) 2026-01-08 13:57:23 -05:00
Aiden Cline
9930ac6929 ci: adjust workflow 2026-01-08 12:50:17 -06:00
Aiden Cline
1906a347f3 docs: update pr workflows 2026-01-08 12:44:04 -06:00
Dax Raad
e5d0c63b29 docs: simplify tools configuration by consolidating under permission field 2026-01-08 12:39:40 -05:00
Aiden Cline
970796b832 docs: add PR title guidelines and workflow to enforce conventional commits 2026-01-08 11:23:52 -06:00
Aiden Cline
3c5043497c ignore: ensure new file truncation stuff still works even if external_directoy is set to deny 2026-01-08 11:07:33 -06:00
Max Stevens
4d09c5618e refactor: session settings simplification (#7342) 2026-01-08 11:06:06 -06:00
Sercan Sagman
adae0d1853 fix(cli): add help text to debug command and subcommands (#7328)
Signed-off-by: assagman <ahmetsercansagman@gmail.com>
2026-01-08 09:42:12 -06:00
Aaron Iker
61aeb2a2a7 feat: Polish dialog, popover shadows & borders, add missing provider logos (#7326) 2026-01-08 09:02:04 -06:00
GitHub Action
4b0f7b82ba ignore: update download stats 2026-01-08 2026-01-08 12:05:02 +00:00
GitHub Action
9fb24074c8 chore: generate 2026-01-08 06:11:00 +00:00
freespace8
542c9d5346 feat(flags): allow disabling .claude prompt and skills loading (#7205) 2026-01-08 00:10:25 -06:00
Aiden Cline
d5f0e3fccc test: add larger image test for read tool 2026-01-07 23:01:02 -06:00
Dax Raad
7d2bb5cb2b tui: remove unused imports and variables from header component to clean up code 2026-01-07 23:54:54 -05:00
Dax
ca7a70b628 tui: add overlay sidebar for narrow screens to improve mobile experience (#7288) 2026-01-07 23:51:49 -05:00
Dax Raad
b3a2f9fb4e tui: add expandable bash output for long commands to improve readability 2026-01-07 23:11:46 -05:00
Dax Raad
8be5a29870 tui: remove username visibility toggle and simplify user message metadata display 2026-01-07 23:02:19 -05:00
Dax Raad
68092f22e1 tui: improve other answer option layout with better indentation and clearer label 2026-01-07 22:34:31 -05:00
GitHub Action
83f3c729e9 chore: generate 2026-01-08 03:30:18 +00:00
Dax
e37fd9c105 core: add interactive question tool for gathering user preferences and clarifying instructions (#7268) 2026-01-07 22:29:42 -05:00
84 changed files with 5133 additions and 3185 deletions

139
.github/workflows/pr-standards.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
name: PR Standards
on:
pull_request_target:
types: [opened, edited, synchronize]
jobs:
check-standards:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check PR standards
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const title = pr.title;
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {
// Label wasn't present, ignore
}
}
async function comment(marker, body) {
const markerText = `<!-- pr-standards:${marker} -->`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(markerText));
if (existing) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: markerText + '\n' + body
});
}
// Step 1: Check title format
// Matches: feat:, feat(scope):, feat (scope):, etc.
const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
const hasValidTitle = titlePattern.test(title);
if (!hasValidTitle) {
await addLabel('needs:title');
await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
Please update it to start with one of:
- \`feat:\` or \`feat(scope):\` new feature
- \`fix:\` or \`fix(scope):\` bug fix
- \`docs:\` or \`docs(scope):\` documentation changes
- \`chore:\` or \`chore(scope):\` maintenance tasks
- \`refactor:\` or \`refactor(scope):\` code refactoring
- \`test:\` or \`test(scope):\` adding or updating tests
Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
return;
}
await removeLabel('needs:title');
// Step 2: Check for linked issue (skip for docs/refactor PRs)
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
if (skipIssueCheck) {
await removeLabel('needs:issue');
console.log('Skipping issue check for docs/refactor PR');
return;
}
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 1) {
totalCount
}
}
}
}
`;
const result = await github.graphql(query, {
owner: context.repo.owner,
repo: context.repo.repo,
number: pr.number
});
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
if (linkedIssues === 0) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
This PR doesn't have a linked issue. All PRs must reference an existing issue.
Please:
1. Open an issue describing the bug/feature (if one doesn't exist)
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
return;
}
await removeLabel('needs:issue');
console.log('PR meets all standards');

View File

@@ -177,8 +177,22 @@ jobs:
cargo tauri --version
- name: Build and upload artifacts
timeout-minutes: 20
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
uses: Wandalen/wretry.action@v3
timeout-minutes: 60
with:
attempt_limit: 3
attempt_delay: 10000
action: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
with: |
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
@@ -190,16 +204,6 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
publish-release:
needs:

View File

@@ -149,11 +149,63 @@ With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
- Try to keep pull requests small and focused.
- Link relevant issue(s) in the description
### Issue First Policy
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Explain the issue and why your change fixes it
- Avoid having verbose LLM generated PR descriptions
- Before adding new functions or functionality, ensure that such behavior doesn't already exist elsewhere in the codebase.
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples:
- `docs: update contributing guidelines`
- `fix: resolve crash on startup`
- `feat: add dark mode support`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
### Style Preferences

View File

@@ -194,3 +194,4 @@
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |

View File

@@ -1,7 +1,8 @@
## Style Guide
- Try to keep things in one function unless composable or reusable
- AVOID unnecessary destructuring of variables
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type

View File

@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -202,7 +202,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -231,7 +231,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -247,7 +247,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.6",
"version": "1.1.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -286,8 +286,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.69",
"@opentui/solid": "0.1.69",
"@opentui/core": "0.1.70",
"@opentui/solid": "0.1.70",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -350,7 +350,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -370,7 +370,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.6",
"version": "1.1.7",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -381,7 +381,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -394,7 +394,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -433,7 +433,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"zod": "catalog:",
},
@@ -444,7 +444,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1201,21 +1201,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.69", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="],
"@opentui/core": ["@opentui/core@0.1.70", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="],
"@opentui/solid": ["@opentui/solid@0.1.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="],
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0="
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.6",
"version": "1.1.7",
"description": "",
"type": "module",
"exports": {

View File

@@ -276,7 +276,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
visible(model: ModelKey) {
const key = `${model.providerID}:${model.modelID}`
const visibility = userVisibilityMap().get(key)
return visibility !== "hide" && (latestSet().has(key) || visibility === "show")
if (visibility === "hide") return false
if (visibility === "show") return true
if (latestSet().has(key)) return true
// For models without valid release_date (e.g. custom models), show by default
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.6",
"version": "1.1.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.6",
"version": "1.1.7",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.6",
"version": "1.1.7",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.6",
"version": "1.1.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.6",
"version": "1.1.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.6",
"version": "1.1.7",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.6"
version = "1.1.7"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.6",
"version": "1.1.7",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.6",
"version": "1.1.7",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -81,8 +81,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.69",
"@opentui/solid": "0.1.69",
"@opentui/core": "0.1.70",
"@opentui/solid": "0.1.70",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -167,6 +167,8 @@ export namespace ACP {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
@@ -242,6 +244,7 @@ export namespace ACP {
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
@@ -260,6 +263,9 @@ export namespace ACP {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",
@@ -491,6 +497,8 @@ export namespace ACP {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
@@ -566,6 +574,7 @@ export namespace ACP {
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
@@ -584,6 +593,9 @@ export namespace ACP {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",

View File

@@ -51,6 +51,7 @@ export namespace Agent {
"*": "ask",
[Truncate.DIR]: "allow",
},
question: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -65,7 +66,13 @@ export namespace Agent {
build: {
name: "build",
options: {},
permission: PermissionNext.merge(defaults, user),
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
}),
user,
),
mode: "primary",
native: true,
},
@@ -75,6 +82,7 @@ export namespace Agent {
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
@@ -201,6 +209,21 @@ export namespace Agent {
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.DIR is allowed unless explicitly configured
for (const name in result) {
const agent = result[name]
const explicit = agent.permission.some(
(r) => r.permission === "external_directory" && r.pattern === Truncate.DIR && r.action === "deny",
)
if (explicit) continue
result[name].permission = PermissionNext.merge(
result[name].permission,
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow" } }),
)
}
return result
})

View File

@@ -6,6 +6,7 @@ import { cmd } from "../cmd"
export const AgentCommand = cmd({
command: "agent <name>",
describe: "show agent configuration details",
builder: (yargs) =>
yargs.positional("name", {
type: "string",

View File

@@ -5,6 +5,7 @@ import { cmd } from "../cmd"
export const ConfigCommand = cmd({
command: "config",
describe: "show resolved configuration",
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {

View File

@@ -6,6 +6,7 @@ import { Ripgrep } from "@/file/ripgrep"
const FileSearchCommand = cmd({
command: "search <query>",
describe: "search files by query",
builder: (yargs) =>
yargs.positional("query", {
type: "string",
@@ -22,6 +23,7 @@ const FileSearchCommand = cmd({
const FileReadCommand = cmd({
command: "read <path>",
describe: "read file contents as JSON",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
@@ -38,6 +40,7 @@ const FileReadCommand = cmd({
const FileStatusCommand = cmd({
command: "status",
describe: "show file status information",
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
@@ -49,6 +52,7 @@ const FileStatusCommand = cmd({
const FileListCommand = cmd({
command: "list <path>",
describe: "list files in a directory",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
@@ -65,6 +69,7 @@ const FileListCommand = cmd({
const FileTreeCommand = cmd({
command: "tree [dir]",
describe: "show directory tree",
builder: (yargs) =>
yargs.positional("dir", {
type: "string",
@@ -79,6 +84,7 @@ const FileTreeCommand = cmd({
export const FileCommand = cmd({
command: "file",
describe: "file system debugging utilities",
builder: (yargs) =>
yargs
.command(FileReadCommand)

View File

@@ -12,6 +12,7 @@ import { AgentCommand } from "./agent"
export const DebugCommand = cmd({
command: "debug",
describe: "debugging and troubleshooting tools",
builder: (yargs) =>
yargs
.command(ConfigCommand)
@@ -25,6 +26,7 @@ export const DebugCommand = cmd({
.command(PathsCommand)
.command({
command: "wait",
describe: "wait indefinitely (for debugging)",
async handler() {
await bootstrap(process.cwd(), async () => {
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
@@ -37,6 +39,7 @@ export const DebugCommand = cmd({
const PathsCommand = cmd({
command: "paths",
describe: "show global paths (data, config, cache, state)",
handler() {
for (const [key, value] of Object.entries(Global.Path)) {
console.log(key.padEnd(10), value)

View File

@@ -6,6 +6,7 @@ import { EOL } from "os"
export const LSPCommand = cmd({
command: "lsp",
describe: "LSP debugging utilities",
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
async handler() {},
@@ -13,6 +14,7 @@ export const LSPCommand = cmd({
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
describe: "get diagnostics for a file",
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap(process.cwd(), async () => {
@@ -25,6 +27,7 @@ const DiagnosticsCommand = cmd({
export const SymbolsCommand = cmd({
command: "symbols <query>",
describe: "search workspace symbols",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap(process.cwd(), async () => {
@@ -37,6 +40,7 @@ export const SymbolsCommand = cmd({
export const DocumentSymbolsCommand = cmd({
command: "document-symbols <uri>",
describe: "get symbols from a document",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap(process.cwd(), async () => {

View File

@@ -6,12 +6,14 @@ import { cmd } from "../cmd"
export const RipgrepCommand = cmd({
command: "rg",
describe: "ripgrep debugging utilities",
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
async handler() {},
})
const TreeCommand = cmd({
command: "tree",
describe: "show file tree using ripgrep",
builder: (yargs) =>
yargs.option("limit", {
type: "number",
@@ -25,6 +27,7 @@ const TreeCommand = cmd({
const FilesCommand = cmd({
command: "files",
describe: "list files using ripgrep",
builder: (yargs) =>
yargs
.option("query", {
@@ -56,6 +59,7 @@ const FilesCommand = cmd({
const SearchCommand = cmd({
command: "search <pattern>",
describe: "search file contents using ripgrep",
builder: (yargs) =>
yargs
.positional("pattern", {

View File

@@ -5,6 +5,7 @@ import { cmd } from "../cmd"
export const ScrapCommand = cmd({
command: "scrap",
describe: "list all known projects",
builder: (yargs) => yargs,
async handler() {
const timer = Log.Default.time("scrap")

View File

@@ -5,6 +5,7 @@ import { cmd } from "../cmd"
export const SkillCommand = cmd({
command: "skill",
describe: "list all available skills",
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {

View File

@@ -4,12 +4,14 @@ import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
describe: "snapshot debugging utilities",
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
async handler() {},
})
const TrackCommand = cmd({
command: "track",
describe: "track current snapshot state",
async handler() {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.track())
@@ -19,6 +21,7 @@ const TrackCommand = cmd({
const PatchCommand = cmd({
command: "patch <hash>",
describe: "show patch for a snapshot hash",
builder: (yargs) =>
yargs.positional("hash", {
type: "string",
@@ -34,6 +37,7 @@ const PatchCommand = cmd({
const DiffCommand = cmd({
command: "diff <hash>",
describe: "show diff for a snapshot hash",
builder: (yargs) =>
yargs.positional("hash", {
type: "string",

View File

@@ -515,7 +515,15 @@ export const GithubRunCommand = cmd({
// Setup opencode session
const repoData = await fetchRepo()
session = await Session.create({})
session = await Session.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
})
subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return

View File

@@ -292,7 +292,28 @@ export const RunCommand = cmd({
: args.title
: undefined
const result = await sdk.session.create(title ? { title } : {})
const result = await sdk.session.create(
title
? {
title,
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}
: {
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
},
)
return result.data?.id
})()

View File

@@ -653,9 +653,17 @@ function ErrorComponent(props: {
mode?: "dark" | "light"
}) {
const term = useTerminalDimensions()
const renderer = useRenderer()
const handleExit = async () => {
renderer.setTerminalTitle("")
renderer.destroy()
props.onExit()
}
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
props.onExit()
handleExit()
}
})
const [copied, setCopied] = createSignal(false)
@@ -708,7 +716,7 @@ function ErrorComponent(props: {
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Reset TUI</text>
</box>
<box onMouseUp={props.onExit} backgroundColor={colors.primary} padding={1}>
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text>
</box>
</box>

View File

@@ -53,6 +53,7 @@ export type AutocompleteRef = {
export type AutocompleteOption = {
display: string
value?: string
aliases?: string[]
disabled?: boolean
description?: string
@@ -221,6 +222,7 @@ export function Autocomplete(props: {
const isDir = item.endsWith("/")
return {
display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: isDir,
path: item,
onSelect: () => {
@@ -259,8 +261,10 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
for (const res of Object.values(sync.data.mcp_resource)) {
const text = `${res.name} (${res.uri})`
options.push({
display: Locale.truncateMiddle(`${res.name} (${res.uri})`, width),
display: Locale.truncateMiddle(text, width),
value: text,
description: res.description,
onSelect: () => {
insertPart(res.name, {
@@ -485,7 +489,11 @@ export function Autocomplete(props: {
}
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
(obj) => obj.aliases?.join(" ") ?? "",
],
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]

View File

@@ -26,7 +26,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
return ready()
},
signal<T>(name: string, defaultValue: T) {
if (!kvStore[name]) setKvStore(name, defaultValue)
if (kvStore[name] === undefined) setKvStore(name, defaultValue)
return [
function () {
return result.get(name)

View File

@@ -8,6 +8,7 @@ import type {
Todo,
Command,
PermissionRequest,
QuestionRequest,
LspStatus,
McpStatus,
McpResource,
@@ -42,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
config: Config
session: Session[]
session_status: {
@@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading",
agent: [],
permission: {},
question: {},
command: [],
provider: [],
provider_default: {},
@@ -142,6 +147,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "question.replied":
case "question.rejected": {
const requests = store.question[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "question.asked": {
const request = event.properties
const requests = store.question[request.sessionID]
if (!requests) {
setStore("question", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("question", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"question",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break

View File

@@ -3,9 +3,8 @@ import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder, EmptyBorder } from "@tui/component/border"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useDirectory } from "../../context/directory"
import { useKeybind } from "../../context/keybind"
const Title = (props: { session: Accessor<Session> }) => {
@@ -33,7 +32,6 @@ export function Header() {
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const cost = createMemo(() => {
const total = pipe(
@@ -99,24 +97,6 @@ export function Header() {
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
<Show when={shareEnabled()}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>copy link</span>
</text>
</Match>
</Switch>
</box>
</box>
</Show>
</Match>
</Switch>
</box>

View File

@@ -1,4 +1,5 @@
import {
batch,
createContext,
createEffect,
createMemo,
@@ -41,6 +42,7 @@ import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -51,7 +53,6 @@ import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history"
import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
@@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
@@ -90,7 +92,6 @@ const context = createContext<{
conceal: () => boolean
showThinking: () => boolean
showTimestamps: () => boolean
usernameVisible: () => boolean
showDetails: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
@@ -118,9 +119,13 @@ export function Session() {
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const permissions = createMemo(() => {
if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
})
const questions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -131,24 +136,25 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebar() === "show") return true
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const showTimestamps = createMemo(() => timestamps() === "show")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
@@ -449,26 +455,10 @@ export function Session() {
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
setSidebar((prev) => {
if (prev === "auto") return sidebarVisible() ? "hide" : "show"
if (prev === "show") return "hide"
return "show"
})
if (sidebar() === "show") kv.set("sidebar", "auto")
if (sidebar() === "hide") kv.set("sidebar", "hide")
dialog.clear()
},
},
{
title: usernameVisible() ? "Hide username" : "Show username",
value: "session.username_visible.toggle",
keybind: "username_toggle",
category: "Session",
onSelect: (dialog) => {
setUsernameVisible((prev) => {
const next = !prev
kv.set("username_visible", next)
return next
batch(() => {
const isVisible = sidebarVisible()
setSidebar(() => (isVisible ? "hide" : "auto"))
setSidebarOpen(!isVisible)
})
dialog.clear()
},
@@ -488,11 +478,7 @@ export function Session() {
value: "session.toggle.timestamps",
category: "Session",
onSelect: (dialog) => {
setShowTimestamps((prev) => {
const next = !prev
kv.set("timestamps", next ? "show" : "hide")
return next
})
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear()
},
},
@@ -501,11 +487,7 @@ export function Session() {
value: "session.toggle.thinking",
category: "Session",
onSelect: (dialog) => {
setShowThinking((prev) => {
const next = !prev
kv.set("thinking_visibility", next)
return next
})
setShowThinking((prev) => !prev)
dialog.clear()
},
},
@@ -524,9 +506,7 @@ export function Session() {
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
const newValue = !showDetails()
setShowDetails(newValue)
kv.set("tool_details_visibility", newValue)
setShowDetails((prev) => !prev)
dialog.clear()
},
},
@@ -536,11 +516,7 @@ export function Session() {
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
setShowScrollbar((prev) => {
const next = !prev
kv.set("scrollbar_visible", next)
return next
})
setShowScrollbar((prev) => !prev)
dialog.clear()
},
},
@@ -549,11 +525,7 @@ export function Session() {
value: "session.toggle.animations",
category: "Session",
onSelect: (dialog) => {
setAnimationsEnabled((prev) => {
const next = !prev
kv.set("animations_enabled", next)
return next
})
setAnimationsEnabled((prev) => !prev)
dialog.clear()
},
},
@@ -907,7 +879,6 @@ export function Session() {
conceal,
showThinking,
showTimestamps,
usernameVisible,
showDetails,
diffWrapMode,
sync,
@@ -916,7 +887,7 @@ export function Session() {
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<Show when={!sidebarVisible()}>
<Show when={!sidebarVisible() || !wide()}>
<Header />
</Show>
<scrollbox
@@ -1037,8 +1008,11 @@ export function Session() {
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Prompt
visible={!session()?.parentID && permissions().length === 0}
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
@@ -1047,21 +1021,35 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
disabled={permissions().length > 0}
disabled={permissions().length > 0 || questions().length > 0}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</box>
<Show when={!sidebarVisible()}>
<Footer />
</Show>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Sidebar sessionID={route.sessionID} />
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
</Show>
</box>
</context.Provider>
@@ -1094,6 +1082,7 @@ function UserMessage(props: {
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
@@ -1123,7 +1112,7 @@ function UserMessage(props: {
>
<text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
@@ -1141,23 +1130,22 @@ function UserMessage(props: {
</For>
</box>
</Show>
<text fg={theme.textMuted}>
{ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "}
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>
{ctx.usernameVisible() ? " · " : " "}
{Locale.todayTimeOrDateTime(props.message.time.created)}
</span>
</Show>
}
>
<span> </span>
</text>
</Show>
}
>
<text fg={theme.textMuted}>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</Show>
</text>
</text>
</Show>
</box>
</box>
</Show>
@@ -1315,8 +1303,16 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
// Pending messages moved to individual tool pending functions
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
const ctx = use()
const sync = useSync()
// Hide tool if showDetails is false and tool completed successfully
const shouldHide = createMemo(() => {
if (ctx.showDetails()) return false
if (props.part.state.status !== "completed") return false
return true
})
const toolprops = {
get metadata() {
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
@@ -1341,50 +1337,55 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
}
return (
<Switch>
<Match when={props.part.tool === "bash"}>
<Bash {...toolprops} />
</Match>
<Match when={props.part.tool === "glob"}>
<Glob {...toolprops} />
</Match>
<Match when={props.part.tool === "read"}>
<Read {...toolprops} />
</Match>
<Match when={props.part.tool === "grep"}>
<Grep {...toolprops} />
</Match>
<Match when={props.part.tool === "list"}>
<List {...toolprops} />
</Match>
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.tool === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "websearch"}>
<WebSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "write"}>
<Write {...toolprops} />
</Match>
<Match when={props.part.tool === "edit"}>
<Edit {...toolprops} />
</Match>
<Match when={props.part.tool === "task"}>
<Task {...toolprops} />
</Match>
<Match when={props.part.tool === "patch"}>
<Patch {...toolprops} />
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWrite {...toolprops} />
</Match>
<Match when={true}>
<GenericTool {...toolprops} />
</Match>
</Switch>
<Show when={!shouldHide()}>
<Switch>
<Match when={props.part.tool === "bash"}>
<Bash {...toolprops} />
</Match>
<Match when={props.part.tool === "glob"}>
<Glob {...toolprops} />
</Match>
<Match when={props.part.tool === "read"}>
<Read {...toolprops} />
</Match>
<Match when={props.part.tool === "grep"}>
<Grep {...toolprops} />
</Match>
<Match when={props.part.tool === "list"}>
<List {...toolprops} />
</Match>
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.tool === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "websearch"}>
<WebSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "write"}>
<Write {...toolprops} />
</Match>
<Match when={props.part.tool === "edit"}>
<Edit {...toolprops} />
</Match>
<Match when={props.part.tool === "task"}>
<Task {...toolprops} />
</Match>
<Match when={props.part.tool === "patch"}>
<Patch {...toolprops} />
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWrite {...toolprops} />
</Match>
<Match when={props.part.tool === "question"}>
<Question {...toolprops} />
</Match>
<Match when={true}>
<GenericTool {...toolprops} />
</Match>
</Switch>
</Show>
)
}
@@ -1442,7 +1443,12 @@ function InlineTool(props: {
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
const denied = createMemo(
() =>
error()?.includes("rejected permission") ||
error()?.includes("specified a rule") ||
error()?.includes("user dismissed"),
)
return (
<box
@@ -1518,15 +1524,30 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
}
function Bash(props: ToolProps<typeof BashTool>) {
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const { theme } = useTheme()
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
return (
<Switch>
<Match when={props.metadata.output !== undefined}>
<BlockTool title={"# " + (props.input.description ?? "Shell")} part={props.part}>
<BlockTool
title={"# " + (props.input.description ?? "Shell")}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>{output()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
</Match>
@@ -1816,6 +1837,40 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
)
}
function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)
function format(answer?: string[]) {
if (!answer?.length) return "(no answer)"
return answer.join(", ")
}
return (
<Switch>
<Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}>
<box>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
Asked {count()} question{count() !== 1 ? "s" : ""}
</InlineTool>
</Match>
</Switch>
)
}
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {

View File

@@ -0,0 +1,368 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
selected: 0,
editing: false,
})
let textarea: TextareaRenderable | undefined
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const other = createMemo(() => store.selected === options().length)
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
return store.answers[store.tab]?.includes(value) ?? false
})
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
sdk.client.question.reply({
requestID: props.request.id,
answers,
})
}
function reject() {
sdk.client.question.reject({
requestID: props.request.id,
})
}
function pick(answer: string, custom: boolean = false) {
const answers = [...store.answers]
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) {
const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
}
if (single()) {
sdk.client.question.reply({
requestID: props.request.id,
answers: [[answer]],
})
return
}
setStore("tab", store.tab + 1)
setStore("selected", 0)
}
function toggle(answer: string) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
}
const dialog = useDialog()
useKeyboard((evt) => {
// When editing "Other" textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
evt.preventDefault()
setStore("editing", false)
return
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
if (!text) {
if (prev) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
}
const answers = [...store.answers]
if (prev) {
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
}
if (!prev) {
answers[store.tab] = []
}
setStore("answers", answers)
setStore("editing", false)
return
}
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = text
setStore("custom", inputs)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
// Let textarea handle all other keys
return
}
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
const next = (store.tab - 1 + tabs()) % tabs()
setStore("tab", next)
setStore("selected", 0)
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
const next = (store.tab + 1) % tabs()
setStore("tab", next)
setStore("selected", 0)
}
if (confirm()) {
if (evt.name === "return") {
evt.preventDefault()
submit()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
} else {
const opts = options()
const total = opts.length + 1 // options + "Other"
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
setStore("selected", (store.selected - 1 + total) % total)
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
setStore("selected", (store.selected + 1) % total)
}
if (evt.name === "return") {
evt.preventDefault()
if (other()) {
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = opts[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Show when={!single()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<For each={questions()}>
{(q, index) => {
const isActive = () => index() === store.tab
const isAnswered = () => {
return (store.answers[index()]?.length ?? 0) > 0
}
return (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
>
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
{q.header}
</text>
</box>
)
}}
</For>
<box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
</box>
</box>
</Show>
<Show when={!confirm()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>
{question()?.question}
{multi() ? " (select all that apply)" : ""}
</text>
</box>
<box>
<For each={options()}>
{(opt, i) => {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box>
<box flexDirection="row" gap={1}>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{i() + 1}. {opt.label}
</text>
</box>
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
</box>
)
}}
</For>
<box>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{options().length + 1}. Type your own answer
</text>
</box>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</box>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{input()}</text>
</box>
</Show>
</box>
</box>
</box>
</Show>
<Show when={confirm() && !single()}>
<box paddingLeft={1}>
<text fg={theme.text}>Review</text>
</box>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{q.header}:</text>
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
</box>
)
}}
</For>
</Show>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<Show when={!single()}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
</text>
</Show>
<Show when={!confirm()}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
</Show>
<text fg={theme.text}>
enter{" "}
<span style={{ fg: theme.textMuted }}>
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>
</box>
</box>
)
}

View File

@@ -12,7 +12,7 @@ import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
export function Sidebar(props: { sessionID: string }) {
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(props.sessionID)!)
@@ -77,6 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox flexGrow={1}>
<box flexShrink={0} gap={1} paddingRight={1}>

View File

@@ -62,6 +62,7 @@ function init() {
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
evt.stopPropagation()
refocus()
}
})

View File

@@ -30,7 +30,7 @@ function getNetworkIPs() {
export const WebCommand = cmd({
command: "web",
builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
describe: "start opencode server and open web interface",
handler: async (args) => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)

View File

@@ -450,6 +450,7 @@ export namespace Config {
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
question: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),

View File

@@ -13,6 +13,11 @@ export namespace Flag {
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"

View File

@@ -348,7 +348,7 @@ export const rustfmt: Info = {
}
export const cargofmt: Info = {
name: "cargo fmt",
name: "cargofmt",
command: ["cargo", "fmt", "--", "$FILE"],
extensions: [".rs"],
async enabled() {

View File

@@ -6,6 +6,7 @@ export namespace Identifier {
session: "ses",
message: "msg",
permission: "per",
question: "que",
user: "usr",
part: "prt",
pty: "pty",

View File

@@ -101,9 +101,9 @@ const cli = yargs(hideBin(process.argv))
.command(SessionCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments") ||
msg.startsWith("Invalid values:")
msg?.startsWith("Unknown argument") ||
msg?.startsWith("Not enough non-option arguments") ||
msg?.startsWith("Invalid values:")
) {
cli.showHelp("log")
}

View File

@@ -1,210 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
export namespace Permission {
const log = Log.create({ service: "permission" })
function toKeys(pattern: Info["pattern"], type: string): string[] {
return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern]
}
function covered(keys: string[], approved: Record<string, boolean>): boolean {
const pats = Object.keys(approved)
return keys.every((k) => pats.some((p) => Wildcard.match(k, p)))
}
export const Info = z
.object({
id: z.string(),
type: z.string(),
pattern: z.union([z.string(), z.array(z.string())]).optional(),
sessionID: z.string(),
messageID: z.string(),
callID: z.string().optional(),
message: z.string(),
metadata: z.record(z.string(), z.any()),
time: z.object({
created: z.number(),
}),
})
.meta({
ref: "Permission",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: BusEvent.define("permission.updated", Info),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
permissionID: z.string(),
response: z.string(),
}),
),
}
const state = Instance.state(
() => {
const pending: {
[sessionID: string]: {
[permissionID: string]: {
info: Info
resolve: () => void
reject: (e: any) => void
}
}
} = {}
const approved: {
[sessionID: string]: {
[permissionID: string]: boolean
}
} = {}
return {
pending,
approved,
}
},
async (state) => {
for (const pending of Object.values(state.pending)) {
for (const item of Object.values(pending)) {
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata))
}
}
},
)
export function pending() {
return state().pending
}
export function list() {
const { pending } = state()
const result: Info[] = []
for (const items of Object.values(pending)) {
for (const item of Object.values(items)) {
result.push(item.info)
}
}
return result.sort((a, b) => a.id.localeCompare(b.id))
}
export async function ask(input: {
type: Info["type"]
message: Info["message"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
messageID: Info["messageID"]
metadata: Info["metadata"]
}) {
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
messageID: input.messageID,
toolCallID: input.callID,
pattern: input.pattern,
})
const approvedForSession = approved[input.sessionID] || {}
const keys = toKeys(input.pattern, input.type)
if (covered(keys, approvedForSession)) return
const info: Info = {
id: Identifier.ascending("permission"),
type: input.type,
pattern: input.pattern,
sessionID: input.sessionID,
messageID: input.messageID,
callID: input.callID,
message: input.message,
metadata: input.metadata,
time: {
created: Date.now(),
},
}
switch (
await Plugin.trigger("permission.ask", info, {
status: "ask",
}).then((x) => x.status)
) {
case "deny":
throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata)
case "allow":
return
}
pending[input.sessionID] = pending[input.sessionID] || {}
return new Promise<void>((resolve, reject) => {
pending[input.sessionID][info.id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Updated, info)
})
}
export const Response = z.enum(["once", "always", "reject"])
export type Response = z.infer<typeof Response>
export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
log.info("response", input)
const { pending, approved } = state()
const match = pending[input.sessionID]?.[input.permissionID]
if (!match) return
delete pending[input.sessionID][input.permissionID]
Bus.publish(Event.Replied, {
sessionID: input.sessionID,
permissionID: input.permissionID,
response: input.response,
})
if (input.response === "reject") {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
match.resolve()
if (input.response === "always") {
approved[input.sessionID] = approved[input.sessionID] || {}
const approveKeys = toKeys(match.info.pattern, match.info.type)
for (const k of approveKeys) {
approved[input.sessionID][k] = true
}
const items = pending[input.sessionID]
if (!items) return
for (const item of Object.values(items)) {
const itemKeys = toKeys(item.info.pattern, item.info.type)
if (covered(itemKeys, approved[input.sessionID])) {
respond({
sessionID: item.info.sessionID,
permissionID: item.info.id,
response: input.response,
})
}
}
}
}
export class RejectedError extends Error {
constructor(
public readonly sessionID: string,
public readonly permissionID: string,
public readonly toolCallID?: string,
public readonly metadata?: Record<string, any>,
public readonly reason?: string,
) {
super(
reason !== undefined
? reason
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
)
}
}
}

View File

@@ -120,28 +120,57 @@ export namespace PermissionNext {
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const ask = (request.patterns ?? []).reduce((ask, pattern) => {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny")
if (rule.action === "deny") {
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
return ask || rule.action === "ask"
}, false)
if (!ask) return
const id = input.id ?? Identifier.ascending("permission")
const info: Request = {
id,
...request,
}
return new Promise<void>((resolve, reject) => {
s.pending[id] = {
info,
resolve,
reject,
}
void import("@/plugin")
.then((plugin) =>
plugin.Plugin.trigger("permission.ask", info, {
status: "ask" as "ask" | "allow" | "deny",
}),
)
.then((result) => {
const existing = s.pending[id]
if (!existing) return
if (result.status === "deny") {
delete s.pending[id]
existing.reject(new RejectedError())
return
}
s.pending[id] = {
info,
resolve,
reject,
if (result.status === "allow") {
delete s.pending[id]
existing.resolve()
return
}
Bus.publish(Event.Asked, info)
})
}
if (rule.action === "allow") continue
}
.catch((error) => {
log.error("permission.ask plugin failed", { error })
if (!s.pending[id]) return
Bus.publish(Event.Asked, info)
})
})
},
)

View File

@@ -0,0 +1,170 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import z from "zod"
export namespace Question {
const log = Log.create({ service: "question" })
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({
ref: "QuestionOption",
})
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().max(12).describe("Very short label (max 12 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
})
.meta({
ref: "QuestionInfo",
})
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: Identifier.schema("question"),
sessionID: Identifier.schema("session"),
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: z.string(),
callID: z.string(),
})
.optional(),
})
.meta({
ref: "QuestionRequest",
})
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({
ref: "QuestionAnswer",
})
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: z.string(),
requestID: z.string(),
}),
),
}
const state = Instance.state(async () => {
const pending: Record<
string,
{
info: Request
resolve: (answers: Answer[]) => void
reject: (e: any) => void
}
> = {}
return {
pending,
}
})
export async function ask(input: {
sessionID: string
questions: Info[]
tool?: { messageID: string; callID: string }
}): Promise<Answer[]> {
const s = await state()
const id = Identifier.ascending("question")
log.info("asking", { id, questions: input.questions.length })
return new Promise<Answer[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
s.pending[id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Asked, info)
})
}
export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
delete s.pending[input.requestID]
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
existing.resolve(input.answers)
}
export async function reject(requestID: string): Promise<void> {
const s = await state()
const existing = s.pending[requestID]
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
delete s.pending[requestID]
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
existing.reject(new RejectedError())
}
export class RejectedError extends Error {
constructor() {
super("The user dismissed this question")
}
}
export async function list() {
return state().then((x) => Object.values(x.pending).map((x) => x.info))
}
}

View File

@@ -0,0 +1,95 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Question } from "../question"
import z from "zod"
import { errors } from "./error"
export const QuestionRoute = new Hono()
.get(
"/",
describeRoute({
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
operationId: "question.list",
responses: {
200: {
description: "List of pending questions",
content: {
"application/json": {
schema: resolver(Question.Request.array()),
},
},
},
},
}),
async (c) => {
const questions = await Question.list()
return c.json(questions)
},
)
.post(
"/:requestID/reply",
describeRoute({
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
operationId: "question.reply",
responses: {
200: {
description: "Question answered successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
},
)
.post(
"/:requestID/reject",
describeRoute({
summary: "Reject question request",
description: "Reject a question request from the AI assistant.",
operationId: "question.reject",
responses: {
200: {
description: "Question rejected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
async (c) => {
const params = c.req.valid("param")
await Question.reject(params.requestID)
return c.json(true)
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -208,7 +209,10 @@ export namespace SessionProcessor {
},
})
if (value.error instanceof PermissionNext.RejectedError) {
if (
value.error instanceof PermissionNext.RejectedError ||
value.error instanceof Question.RejectedError
) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]

View File

@@ -62,10 +62,10 @@ export namespace SystemPrompt {
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
const GLOBAL_RULE_FILES = [
path.join(Global.Path.config, "AGENTS.md"),
path.join(os.homedir(), ".claude", "CLAUDE.md"),
]
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
if (Flag.OPENCODE_CONFIG_DIR) {
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))

View File

@@ -7,6 +7,7 @@ import { Log } from "../util/log"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { exists } from "fs/promises"
import { Flag } from "@/flag/flag"
export namespace Skill {
const log = Log.create({ service: "skill" })
@@ -80,22 +81,24 @@ export namespace Skill {
claudeDirs.push(globalClaude)
}
for (const dir of claudeDirs) {
const matches = await Array.fromAsync(
CLAUDE_SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
}),
).catch((error) => {
log.error("failed .claude directory scan for skills", { dir, error })
return []
})
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
for (const dir of claudeDirs) {
const matches = await Array.fromAsync(
CLAUDE_SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
}),
).catch((error) => {
log.error("failed .claude directory scan for skills", { dir, error })
return []
})
for (const match of matches) {
await addSkill(match)
for (const match of matches) {
await addSkill(match)
}
}
}

View File

@@ -0,0 +1,33 @@
import z from "zod"
import { Tool } from "./tool"
import { Question } from "../question"
import DESCRIPTION from "./question.txt"
export const QuestionTool = Tool.define("question", {
description: DESCRIPTION,
parameters: z.object({
questions: z.array(Question.Info).describe("Questions to ask"),
}),
async execute(params, ctx) {
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
function format(answer: Question.Answer | undefined) {
if (!answer?.length) return "Unanswered"
return answer.join(", ")
}
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
},
})

View File

@@ -0,0 +1,10 @@
Use this tool when you need to ask the user questions during execution. This allows you to:
1. Gather user preferences or requirements
2. Clarify ambiguous instructions
3. Get decisions on implementation choices as you work
4. Offer choices to the user about what direction to take.
Usage notes:
- Users will always be able to select "Other" to provide custom text input
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label

View File

@@ -1,3 +1,4 @@
import { QuestionTool } from "./question"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -92,6 +93,7 @@ export namespace ToolRegistry {
return [
InvalidTool,
...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,

View File

@@ -446,3 +446,66 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
},
})
})
test("Truncate.DIR is allowed even when user denies external_directory globally", async () => {
const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({
config: {
permission: {
external_directory: "deny",
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
test("Truncate.DIR is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({
config: {
agent: {
build: {
permission: {
external_directory: "deny",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
test("explicit Truncate.DIR deny is respected", async () => {
const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({
config: {
permission: {
external_directory: {
"*": "deny",
[Truncate.DIR]: "deny",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
},
})
})

View File

@@ -0,0 +1,300 @@
import { test, expect } from "bun:test"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
test("ask - returns pending promise", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
expect(promise).toBeInstanceOf(Promise)
},
})
})
test("ask - adds to pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]
Question.ask({
sessionID: "ses_test",
questions,
})
const pending = await Question.list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
},
})
})
// reply tests
test("reply - resolves the pending ask with answers", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]
const askPromise = Question.ask({
sessionID: "ses_test",
questions,
})
const pending = await Question.list()
const requestID = pending[0].id
await Question.reply({
requestID,
answers: [["Option 1"]],
})
const answers = await askPromise
expect(answers).toEqual([["Option 1"]])
},
})
})
test("reply - removes from pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await Question.list()
expect(pending.length).toBe(1)
await Question.reply({
requestID: pending[0].id,
answers: [["Option 1"]],
})
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
},
})
})
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reply({
requestID: "que_unknown",
answers: [["Option 1"]],
})
// Should not throw
},
})
})
// reject tests
test("reject - throws RejectedError", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await Question.list()
await Question.reject(pending[0].id)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
},
})
})
test("reject - removes from pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
sessionID: "ses_test",
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await Question.list()
expect(pending.length).toBe(1)
await Question.reject(pending[0].id)
askPromise.catch(() => {}) // Ignore rejection
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
},
})
})
test("reject - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reject("que_unknown")
// Should not throw
},
})
})
// multiple questions tests
test("ask - handles multiple questions", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Build", description: "Build the project" },
{ label: "Test", description: "Run tests" },
],
},
{
question: "Which environment?",
header: "Env",
options: [
{ label: "Dev", description: "Development" },
{ label: "Prod", description: "Production" },
],
},
]
const askPromise = Question.ask({
sessionID: "ses_test",
questions,
})
const pending = await Question.list()
await Question.reply({
requestID: pending[0].id,
answers: [["Build"], ["Dev"]],
})
const answers = await askPromise
expect(answers).toEqual([["Build"], ["Dev"]])
},
})
})
// list tests
test("list - returns all pending requests", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
Question.ask({
sessionID: "ses_test1",
questions: [
{
question: "Question 1?",
header: "Q1",
options: [{ label: "A", description: "A" }],
},
],
})
Question.ask({
sessionID: "ses_test2",
questions: [
{
question: "Question 2?",
header: "Q2",
options: [{ label: "B", description: "B" }],
},
],
})
const pending = await Question.list()
expect(pending.length).toBe(2)
},
})
})
test("list - returns empty when no pending", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending.length).toBe(0)
},
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -286,4 +286,18 @@ describe("tool.read truncation", () => {
},
})
})
test("large image files are properly attached without error", async () => {
await Instance.provide({
directory: FIXTURES_DIR,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(FIXTURES_DIR, "large-image.png") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
},
})
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.6",
"version": "1.1.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.6",
"version": "1.1.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -84,6 +84,12 @@ import type {
PtyRemoveResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
QuestionListResponses,
QuestionRejectErrors,
QuestionRejectResponses,
QuestionReplyErrors,
QuestionReplyResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@@ -1781,6 +1787,94 @@ export class Permission extends HeyApiClient {
}
}
export class Question extends HeyApiClient {
/**
* List pending questions
*
* Get all pending question requests across all sessions.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({
url: "/question",
...options,
...params,
})
}
/**
* Reply to question request
*
* Provide answers to a question request from the AI assistant.
*/
public reply<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
answers?: Array<QuestionAnswer>
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
{ in: "body", key: "answers" },
],
},
],
)
return (options?.client ?? this.client).post<QuestionReplyResponses, QuestionReplyErrors, ThrowOnError>({
url: "/question/{requestID}/reply",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Reject question request
*
* Reject a question request from the AI assistant.
*/
public reject<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
],
},
],
)
return (options?.client ?? this.client).post<QuestionRejectResponses, QuestionRejectErrors, ThrowOnError>({
url: "/question/{requestID}/reject",
...options,
...params,
})
}
}
export class Command extends HeyApiClient {
/**
* List commands
@@ -2912,6 +3006,8 @@ export class OpencodeClient extends HeyApiClient {
permission = new Permission({ client: this.client })
question = new Question({ client: this.client })
command = new Command({ client: this.client })
provider = new Provider({ client: this.client })

View File

@@ -517,6 +517,73 @@ export type EventSessionIdle = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 12 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
@@ -788,6 +855,9 @@ export type Event =
| EventPermissionReplied
| EventSessionStatus
| EventSessionIdle
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventSessionCompacted
| EventFileEdited
| EventTodoUpdated
@@ -1233,6 +1303,7 @@ export type PermissionConfig =
external_directory?: PermissionRuleConfig
todowrite?: PermissionActionConfig
todoread?: PermissionActionConfig
question?: PermissionActionConfig
webfetch?: PermissionActionConfig
websearch?: PermissionActionConfig
codesearch?: PermissionActionConfig
@@ -3545,6 +3616,95 @@ export type PermissionListResponses = {
export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
export type QuestionListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/question"
}
export type QuestionListResponses = {
/**
* List of pending questions
*/
200: Array<QuestionRequest>
}
export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]
export type QuestionReplyData = {
body?: {
/**
* User answers in order of questions (each answer is an array of selected labels)
*/
answers: Array<QuestionAnswer>
}
path: {
requestID: string
}
query?: {
directory?: string
}
url: "/question/{requestID}/reply"
}
export type QuestionReplyErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]
export type QuestionReplyResponses = {
/**
* Question answered successfully
*/
200: boolean
}
export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses]
export type QuestionRejectData = {
body?: never
path: {
requestID: string
}
query?: {
directory?: string
}
url: "/question/{requestID}/reject"
}
export type QuestionRejectErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]
export type QuestionRejectResponses = {
/**
* Question rejected successfully
*/
200: boolean
}
export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]
export type CommandListData = {
body?: never
path?: never

View File

@@ -3156,6 +3156,186 @@
]
}
},
"/question": {
"get": {
"operationId": "question.list",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
}
],
"summary": "List pending questions",
"description": "Get all pending question requests across all sessions.",
"responses": {
"200": {
"description": "List of pending questions",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionRequest"
}
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})"
}
]
}
},
"/question/{requestID}/reply": {
"post": {
"operationId": "question.reply",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "requestID",
"schema": {
"type": "string"
},
"required": true
}
],
"summary": "Reply to question request",
"description": "Provide answers to a question request from the AI assistant.",
"responses": {
"200": {
"description": "Question answered successfully",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"answers": {
"description": "User answers in order of questions (each answer is an array of selected labels)",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["answers"]
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})"
}
]
}
},
"/question/{requestID}/reject": {
"post": {
"operationId": "question.reject",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "requestID",
"schema": {
"type": "string"
},
"required": true
}
],
"summary": "Reject question request",
"description": "Reject a question request from the AI assistant.",
"responses": {
"200": {
"description": "Question rejected successfully",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})"
}
]
}
},
"/command": {
"get": {
"operationId": "command.list",
@@ -6906,6 +7086,148 @@
},
"required": ["type", "properties"]
},
"QuestionOption": {
"type": "object",
"properties": {
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string"
},
"description": {
"description": "Explanation of choice",
"type": "string"
}
},
"required": ["label", "description"]
},
"QuestionInfo": {
"type": "object",
"properties": {
"question": {
"description": "Complete question",
"type": "string"
},
"header": {
"description": "Very short label (max 12 chars)",
"type": "string",
"maxLength": 12
},
"options": {
"description": "Available choices",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionOption"
}
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean"
}
},
"required": ["question", "header", "options"]
},
"QuestionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^que.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"questions": {
"description": "Questions to ask",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionInfo"
}
},
"tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "questions"]
},
"Event.question.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.asked"
},
"properties": {
"$ref": "#/components/schemas/QuestionRequest"
}
},
"required": ["type", "properties"]
},
"QuestionAnswer": {
"type": "array",
"items": {
"type": "string"
}
},
"Event.question.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.replied"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
},
"requestID": {
"type": "string"
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["sessionID", "requestID", "answers"]
}
},
"required": ["type", "properties"]
},
"Event.question.rejected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.rejected"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
},
"requestID": {
"type": "string"
}
},
"required": ["sessionID", "requestID"]
}
},
"required": ["type", "properties"]
},
"Event.session.compacted": {
"type": "object",
"properties": {
@@ -7630,6 +7952,15 @@
{
"$ref": "#/components/schemas/Event.session.idle"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.session.compacted"
},
@@ -8289,6 +8620,9 @@
"todoread": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"question": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"webfetch": {
"$ref": "#/components/schemas/PermissionActionConfig"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.6",
"version": "1.1.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.6",
"version": "1.1.7",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -0,0 +1,14 @@
<svg width="24" height="24" viewBox="0 0 40 40" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M6.93433 9.57807C6.93433 8.92952 7.46008 8.40376 8.10864 8.40376C8.75719 8.40376 9.28295 8.92952 9.28295 9.57807V16.6239C9.28295 17.2725 8.75719 17.7983 8.10864 17.7983C7.46008 17.7983 6.93433 17.2725 6.93433 16.6239V9.57807Z" />
<path d="M30.7144 25.1377C30.7144 24.4891 31.2402 23.9634 31.8887 23.9634C32.5373 23.9634 33.063 24.4891 33.063 25.1377V31.0092C33.063 31.6578 32.5373 32.1836 31.8887 32.1836C31.2402 32.1836 30.7144 31.6578 30.7144 31.0093V25.1377Z" />
<path d="M22.7875 30.422C22.7875 29.7735 23.3132 29.2477 23.9618 29.2477C24.6103 29.2477 25.1361 29.7735 25.1361 30.422V34.8257C25.1361 35.4742 24.6103 36 23.9618 36C23.3132 36 22.7875 35.4742 22.7875 34.8257V30.422Z" />
<path d="M6.93433 28.367C6.93433 27.7185 7.46008 27.1927 8.10864 27.1927C8.75719 27.1927 9.28295 27.7185 9.28295 28.367V31.5964C9.28295 32.245 8.75719 32.7707 8.10864 32.7707C7.46008 32.7707 6.93433 32.245 6.93433 31.5964V28.367Z" />
<path d="M14.8617 5.46787C14.8617 4.81931 15.3874 4.29355 16.036 4.29355C16.6845 4.29355 17.2103 4.81931 17.2103 5.46787V9.87153C17.2103 10.5201 16.6845 11.0458 16.036 11.0458C15.3874 11.0458 14.8617 10.5201 14.8617 9.87153V5.46787Z" />
<path d="M30.7144 9.28437C30.7144 8.63582 31.2402 8.11006 31.8887 8.11006C32.5373 8.11006 33.063 8.63582 33.063 9.28437V13.688C33.063 14.3366 32.5373 14.8623 31.8887 14.8623C31.2402 14.8623 30.7144 14.3366 30.7144 13.688V9.28437Z" />
<path d="M22.7875 5.17431C22.7875 4.52576 23.3132 4 23.9618 4C24.6103 4 25.1361 4.52576 25.1361 5.17431V18.3853C25.1361 19.0339 24.6103 19.5596 23.9618 19.5596C23.3132 19.5596 22.7875 19.0339 22.7875 18.3853V5.17431Z" />
<path d="M14.8617 21.3212C14.8617 20.6726 15.3874 20.1469 16.036 20.1469C16.6845 20.1469 17.2103 20.6726 17.2103 21.3212V34.5322C17.2103 35.1807 16.6845 35.7065 16.036 35.7065C15.3874 35.7065 14.8617 35.1807 14.8617 34.5322V21.3212Z" />
<path d="M10.4577 21.9083C10.4577 23.2055 9.40623 24.257 8.10912 24.257C6.81201 24.257 5.7605 23.2055 5.7605 21.9083C5.7605 20.6112 6.81201 19.5597 8.10912 19.5597C9.40623 19.5597 10.4577 20.6112 10.4577 21.9083Z" />
<path d="M18.3843 14.8624C18.3843 16.1596 17.3328 17.2111 16.0357 17.2111C14.7386 17.2111 13.6871 16.1596 13.6871 14.8624C13.6871 13.5653 14.7386 12.5138 16.0357 12.5138C17.3328 12.5138 18.3843 13.5653 18.3843 14.8624Z" />
<path d="M26.3101 24.2569C26.3101 25.554 25.2586 26.6056 23.9615 26.6056C22.6644 26.6056 21.6128 25.554 21.6128 24.2569C21.6128 22.9598 22.6644 21.9083 23.9615 21.9083C25.2586 21.9083 26.3101 22.9598 26.3101 24.2569Z" />
<path d="M34.2378 19.5597C34.2378 20.8568 33.1863 21.9083 31.8892 21.9083C30.5921 21.9083 29.5406 20.8568 29.5406 19.5597C29.5406 18.2626 30.5921 17.2111 31.8892 17.2111C33.1863 17.2111 34.2378 18.2626 34.2378 19.5597Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.1484 16.5153V16.5238C14.5325 17.3971 14.4945 18.6923 14.5114 19.3925V21.4091C14.5114 22.7379 13.4272 23.8221 12.1067 23.8221H9.24219C7.91327 23.8221 6.8375 22.7423 6.8375 21.4091V18.5952C6.8375 17.262 7.91327 16.182 9.24219 16.182H10.9339C11.2376 16.182 11.9717 16.1694 12.2923 16.1061C12.5666 16.0513 13.4525 15.853 14.2963 15.2201C14.3891 15.1526 14.4734 15.0852 14.5451 15.0219C14.5451 15.0219 14.7392 14.8574 14.9122 14.6759C15.3889 14.1824 15.7559 13.4778 15.7559 13.4778C15.8741 13.2584 16.1441 12.7016 16.3044 11.9295C16.3803 11.5541 16.4141 11.2039 16.4394 10.917L16.528 9.24219C16.528 7.91327 17.6079 6.83329 18.9369 6.83329H21.7339C23.0629 6.83329 24.1427 7.91327 24.1427 9.24219V12.0603C24.1427 13.3892 23.0629 14.4692 21.7339 14.4692H19.4346C18.9073 14.4819 18.4686 14.5367 18.1438 14.5958C17.5489 14.7055 17.2409 14.832 17.1481 14.87M10.6428 14.8109C8.35625 14.8109 6.5 12.9505 6.5 10.6555C6.5 8.36046 8.35625 6.5 10.6428 6.5C12.9294 6.5 14.7856 8.36046 14.7856 10.6555C14.7856 12.9505 12.9294 14.8109 10.6428 14.8109ZM10.6428 25.1891C12.9294 25.1891 14.7856 27.0496 14.7856 29.3446C14.7856 31.6396 12.9294 33.5 10.6428 33.5C8.35625 33.5 6.5 31.6396 6.5 29.3446C6.5 27.0496 8.35625 25.1891 10.6428 25.1891ZM33.5 12.1405C33.5 13.4272 32.4538 14.4734 31.1671 14.4734C30.4963 14.4355 29.9352 14.4566 29.526 14.4819C28.8341 14.5283 28.429 14.5536 27.8933 14.7181C27.29 14.9037 26.8598 15.1611 26.615 15.3087C26.5096 15.372 26.0919 15.6294 25.6067 16.0597C25.3325 16.3044 25.0709 16.5364 24.8009 16.9076C24.4254 17.4265 24.261 17.899 24.1766 18.1521C23.9487 18.8356 23.9066 19.7848 23.9192 20.2448L23.8939 21.4808C23.8939 22.7717 22.8519 23.8179 21.5652 23.8179H18.6079C17.3211 23.8179 16.2791 22.7717 16.2791 21.4808V18.515C16.2791 17.2241 17.3211 16.1778 18.6079 16.1778H20.3965C21.0294 16.1778 22.2865 16.2326 23.3539 15.8403C24.2694 15.507 25.1848 14.5789 25.5434 13.5622C25.8852 12.5286 25.8767 11.1533 25.8767 11.1533L25.8852 9.16625C25.8852 7.87952 26.9314 6.83329 28.2181 6.83329H31.1671C32.4538 6.83329 33.5 7.87952 33.5 9.16625V12.1405Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M26.7,5.25l2.14-1.53,10.49,13.29-16.23,19.43-2.65,3L0.42,16.83l6.87-8.64,5.78,2.21,1.79-4.46L23.3,0.1l3.42,5.15ZM31.13,13.98L22.91,1.78l-7.1,4.95-5.45,13.53,20.77-6.28ZM36.74,15.57l-8.03-10.1-0.14-0.1-1.14,0.89,5.03,7.58,4.28,1.73ZM9.06,20.35l3.53-8.84-4.89-1.92-5.62,7.1,6.98,3.66ZM37.62,17.19l-5.3-2.14-9.22,19.52,14.52-17.38ZM30.95,15.24l-20.63,6.22,10.1,15.93,10.53-22.15ZM15.79,32.41l-6.86-10.82c-1.61-0.84-3.2-1.73-4.81-2.57-0.1-0.05-0.21-0.12-0.32-0.13l11.99,13.52Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@@ -43,14 +43,15 @@
/* padding: 8px; */
/* padding: 8px 8px 0 8px; */
border: 1px solid hsl(from var(--border-base) h s l / 0.2);
border: 1px solid
light-dark(
color-mix(in oklch, var(--border-base) 30%, transparent),
color-mix(in oklch, var(--border-base) 50%, transparent)
);
border-radius: var(--radius-xl);
background: var(--surface-raised-stronger-non-alpha);
background-clip: padding-box;
box-shadow:
0 15px 45px 0 rgba(19, 16, 16, 0.35),
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
box-shadow: var(--shadow-lg);
/* animation: contentHide 300ms ease-in forwards; */
/**/

View File

@@ -3,7 +3,8 @@
min-width: 8rem;
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid var(--border-weak-base);
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
box-shadow: var(--shadow-md);

View File

@@ -7,9 +7,12 @@
min-width: 200px;
max-width: 320px;
border-radius: var(--radius-md);
border: 1px solid var(--border-weak-base);
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
box-shadow: var(--shadow-md);
transform-origin: var(--kb-popover-content-transform-origin);
&:focus-within {

View File

@@ -382,6 +382,12 @@
fill="currentColor"
></path>
</symbol>
<symbol viewBox="0 0 40 40" id="nano-gpt">
<path
d="M26.7,5.25l2.14-1.53,10.49,13.29-16.23,19.43-2.65,3L0.42,16.83l6.87-8.64,5.78,2.21,1.79-4.46L23.3,0.1l3.42,5.15ZM31.13,13.98L22.91,1.78l-7.1,4.95-5.45,13.53,20.77-6.28ZM36.74,15.57l-8.03-10.1-0.14-0.1-1.14,0.89,5.03,7.58,4.28,1.73ZM9.06,20.35l3.53-8.84-4.89-1.92-5.62,7.1,6.98,3.66ZM37.62,17.19l-5.3-2.14-9.22,19.52,14.52-17.38ZM30.95,15.24l-20.63,6.22,10.1,15.93,10.53-22.15ZM15.79,32.41l-6.86-10.82c-1.61-0.84-3.2-1.73-4.81-2.57-0.1-0.05-0.21-0.12-0.32-0.13l11.99,13.52Z"
fill="currentColor"
></path>
</symbol>
<symbol viewBox="0 0 24 24" fill="none" id="morph">
<path
shape-rendering="geometricPrecision"
@@ -632,6 +638,12 @@
fill="currentColor"
></path>
</symbol>
<symbol viewBox="0 0 40 40" fill="none" id="friendli">
<path
d="M15.1484 16.5153V16.5238C14.5325 17.3971 14.4945 18.6923 14.5114 19.3925V21.4091C14.5114 22.7379 13.4272 23.8221 12.1067 23.8221H9.24219C7.91327 23.8221 6.8375 22.7423 6.8375 21.4091V18.5952C6.8375 17.262 7.91327 16.182 9.24219 16.182H10.9339C11.2376 16.182 11.9717 16.1694 12.2923 16.1061C12.5666 16.0513 13.4525 15.853 14.2963 15.2201C14.3891 15.1526 14.4734 15.0852 14.5451 15.0219C14.5451 15.0219 14.7392 14.8574 14.9122 14.6759C15.3889 14.1824 15.7559 13.4778 15.7559 13.4778C15.8741 13.2584 16.1441 12.7016 16.3044 11.9295C16.3803 11.5541 16.4141 11.2039 16.4394 10.917L16.528 9.24219C16.528 7.91327 17.6079 6.83329 18.9369 6.83329H21.7339C23.0629 6.83329 24.1427 7.91327 24.1427 9.24219V12.0603C24.1427 13.3892 23.0629 14.4692 21.7339 14.4692H19.4346C18.9073 14.4819 18.4686 14.5367 18.1438 14.5958C17.5489 14.7055 17.2409 14.832 17.1481 14.87M10.6428 14.8109C8.35625 14.8109 6.5 12.9505 6.5 10.6555C6.5 8.36046 8.35625 6.5 10.6428 6.5C12.9294 6.5 14.7856 8.36046 14.7856 10.6555C14.7856 12.9505 12.9294 14.8109 10.6428 14.8109ZM10.6428 25.1891C12.9294 25.1891 14.7856 27.0496 14.7856 29.3446C14.7856 31.6396 12.9294 33.5 10.6428 33.5C8.35625 33.5 6.5 31.6396 6.5 29.3446C6.5 27.0496 8.35625 25.1891 10.6428 25.1891ZM33.5 12.1405C33.5 13.4272 32.4538 14.4734 31.1671 14.4734C30.4963 14.4355 29.9352 14.4566 29.526 14.4819C28.8341 14.5283 28.429 14.5536 27.8933 14.7181C27.29 14.9037 26.8598 15.1611 26.615 15.3087C26.5096 15.372 26.0919 15.6294 25.6067 16.0597C25.3325 16.3044 25.0709 16.5364 24.8009 16.9076C24.4254 17.4265 24.261 17.899 24.1766 18.1521C23.9487 18.8356 23.9066 19.7848 23.9192 20.2448L23.8939 21.4808C23.8939 22.7717 22.8519 23.8179 21.5652 23.8179H18.6079C17.3211 23.8179 16.2791 22.7717 16.2791 21.4808V18.515C16.2791 17.2241 17.3211 16.1778 18.6079 16.1778H20.3965C21.0294 16.1778 22.2865 16.2326 23.3539 15.8403C24.2694 15.507 25.1848 14.5789 25.5434 13.5622C25.8852 12.5286 25.8767 11.1533 25.8767 11.1533L25.8852 9.16625C25.8852 7.87952 26.9314 6.83329 28.2181 6.83329H31.1671C32.4538 6.83329 33.5 7.87952 33.5 9.16625V12.1405Z"
fill="currentColor"
></path>
</symbol>
<symbol viewBox="0 0 24 24" fill="none" id="fireworks-ai">
<path
d="M14.45 5.74999L11.9991 11.6956L9.54562 5.74999H7.97237L10.6604 12.2495C10.7678 12.5138 10.9515 12.7402 11.1882 12.8996C11.4248 13.059 11.7036 13.1442 11.9889 13.1444C12.2742 13.1446 12.5531 13.0597 12.79 12.9006C13.0268 12.7415 13.2109 12.5154 13.3186 12.2512L16.0232 5.74999H14.45ZM15.4965 14.808L19.98 10.2195L19.3684 8.75911L14.4719 13.7807C14.272 13.9856 14.1369 14.2448 14.0835 14.5261C14.0301 14.8073 14.0608 15.098 14.1718 15.3619C14.2807 15.624 14.4648 15.848 14.7008 16.0056C14.9369 16.1632 15.2144 16.2473 15.4983 16.2474L15.5 16.25L22.5 16.2325L21.8884 14.7721L15.4983 14.808H15.4965ZM4.02 10.216L4.63162 8.75561L9.52813 13.7772C9.93763 14.1964 10.0557 14.8176 9.82825 15.3584C9.71925 15.6204 9.53511 15.8443 9.29905 16.0019C9.06299 16.1595 8.78557 16.2437 8.50175 16.2439L1.50175 16.2281L1.5 16.2299L2.11163 14.7695L8.50175 14.8062L4.02 10.216Z"
@@ -828,5 +840,43 @@
fill="currentColor"
></path>
</symbol>
<symbol viewBox="0 0 40 40" fill="currentColor" id="abacus">
<path
d="M6.93433 9.57807C6.93433 8.92952 7.46008 8.40376 8.10864 8.40376C8.75719 8.40376 9.28295 8.92952 9.28295 9.57807V16.6239C9.28295 17.2725 8.75719 17.7983 8.10864 17.7983C7.46008 17.7983 6.93433 17.2725 6.93433 16.6239V9.57807Z"
></path>
<path
d="M30.7144 25.1377C30.7144 24.4891 31.2402 23.9634 31.8887 23.9634C32.5373 23.9634 33.063 24.4891 33.063 25.1377V31.0092C33.063 31.6578 32.5373 32.1836 31.8887 32.1836C31.2402 32.1836 30.7144 31.6578 30.7144 31.0093V25.1377Z"
></path>
<path
d="M22.7875 30.422C22.7875 29.7735 23.3132 29.2477 23.9618 29.2477C24.6103 29.2477 25.1361 29.7735 25.1361 30.422V34.8257C25.1361 35.4742 24.6103 36 23.9618 36C23.3132 36 22.7875 35.4742 22.7875 34.8257V30.422Z"
></path>
<path
d="M6.93433 28.367C6.93433 27.7185 7.46008 27.1927 8.10864 27.1927C8.75719 27.1927 9.28295 27.7185 9.28295 28.367V31.5964C9.28295 32.245 8.75719 32.7707 8.10864 32.7707C7.46008 32.7707 6.93433 32.245 6.93433 31.5964V28.367Z"
></path>
<path
d="M14.8617 5.46787C14.8617 4.81931 15.3874 4.29355 16.036 4.29355C16.6845 4.29355 17.2103 4.81931 17.2103 5.46787V9.87153C17.2103 10.5201 16.6845 11.0458 16.036 11.0458C15.3874 11.0458 14.8617 10.5201 14.8617 9.87153V5.46787Z"
></path>
<path
d="M30.7144 9.28437C30.7144 8.63582 31.2402 8.11006 31.8887 8.11006C32.5373 8.11006 33.063 8.63582 33.063 9.28437V13.688C33.063 14.3366 32.5373 14.8623 31.8887 14.8623C31.2402 14.8623 30.7144 14.3366 30.7144 13.688V9.28437Z"
></path>
<path
d="M22.7875 5.17431C22.7875 4.52576 23.3132 4 23.9618 4C24.6103 4 25.1361 4.52576 25.1361 5.17431V18.3853C25.1361 19.0339 24.6103 19.5596 23.9618 19.5596C23.3132 19.5596 22.7875 19.0339 22.7875 18.3853V5.17431Z"
></path>
<path
d="M14.8617 21.3212C14.8617 20.6726 15.3874 20.1469 16.036 20.1469C16.6845 20.1469 17.2103 20.6726 17.2103 21.3212V34.5322C17.2103 35.1807 16.6845 35.7065 16.036 35.7065C15.3874 35.7065 14.8617 35.1807 14.8617 34.5322V21.3212Z"
></path>
<path
d="M10.4577 21.9083C10.4577 23.2055 9.40623 24.257 8.10912 24.257C6.81201 24.257 5.7605 23.2055 5.7605 21.9083C5.7605 20.6112 6.81201 19.5597 8.10912 19.5597C9.40623 19.5597 10.4577 20.6112 10.4577 21.9083Z"
></path>
<path
d="M18.3843 14.8624C18.3843 16.1596 17.3328 17.2111 16.0357 17.2111C14.7386 17.2111 13.6871 16.1596 13.6871 14.8624C13.6871 13.5653 14.7386 12.5138 16.0357 12.5138C17.3328 12.5138 18.3843 13.5653 18.3843 14.8624Z"
></path>
<path
d="M26.3101 24.2569C26.3101 25.554 25.2586 26.6056 23.9615 26.6056C22.6644 26.6056 21.6128 25.554 21.6128 24.2569C21.6128 22.9598 22.6644 21.9083 23.9615 21.9083C25.2586 21.9083 26.3101 22.9598 26.3101 24.2569Z"
></path>
<path
d="M34.2378 19.5597C34.2378 20.8568 33.1863 21.9083 31.8892 21.9083C30.5921 21.9083 29.5406 20.8568 29.5406 19.5597C29.5406 18.2626 30.5921 17.2111 31.8892 17.2111C33.1863 17.2111 34.2378 18.2626 34.2378 19.5597Z"
></path>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -31,6 +31,7 @@ export const iconNames = [
"ollama-cloud",
"nvidia",
"nebius",
"nano-gpt",
"morph",
"moonshotai",
"moonshotai-cn",
@@ -54,6 +55,7 @@ export const iconNames = [
"google-vertex-anthropic",
"github-models",
"github-copilot",
"friendli",
"fireworks-ai",
"fastrouter",
"deepseek",
@@ -73,6 +75,7 @@ export const iconNames = [
"alibaba",
"alibaba-cn",
"aihubmix",
"abacus",
] as const
export type IconName = (typeof iconNames)[number]

View File

@@ -47,9 +47,17 @@
--radius-xl: 0.625rem;
--shadow-xs:
0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);
0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.06)),
0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.08)),
0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.1));
--shadow-md:
0 6px 8px -4px rgba(19, 16, 16, 0.12), 0 4px 3px -2px rgba(19, 16, 16, 0.12), 0 1px 2px -1px rgba(19, 16, 16, 0.12);
0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.1)),
0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.15)),
0 1px 2px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.15));
--shadow-lg:
0 16px 48px -6px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)),
0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)),
0 1px 2.5px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1));
--shadow-xs-border:
0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04),
0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.6",
"version": "1.1.7",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.6",
"version": "1.1.7",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -551,23 +551,26 @@ The opencode CLI takes the following global flags.
OpenCode can be configured using environment variables.
| Variable | Type | Description |
| ------------------------------------- | ------- | ---------------------------------------- |
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
| `OPENCODE_DISABLE_PRUNE` | boolean | Disable pruning of old data |
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Disable automatic terminal title updates |
| `OPENCODE_PERMISSION` | string | Inlined json permissions config |
| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Disable default plugins |
| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Disable automatic LSP server downloads |
| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Enable experimental models |
| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Disable automatic context compaction |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
| Variable | Type | Description |
| ------------------------------------- | ------- | ------------------------------------------------ |
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
| `OPENCODE_DISABLE_PRUNE` | boolean | Disable pruning of old data |
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Disable automatic terminal title updates |
| `OPENCODE_PERMISSION` | string | Inlined json permissions config |
| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Disable default plugins |
| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Disable automatic LSP server downloads |
| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Enable experimental models |
| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Disable automatic context compaction |
| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Disable reading from `.claude` (prompt + skills) |
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
---

View File

@@ -22,6 +22,7 @@ OpenCode comes with several built-in formatters for popular languages and framew
| ktlint | .kt, .kts | `ktlint` command available |
| ruff | .py, .pyi | `ruff` command available with config |
| rustfmt | .rs | `rustfmt` command available |
| cargofmt | .rs | `cargo fmt` command available |
| uv | .py, .pyi | `uv` command available |
| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |

View File

@@ -5,86 +5,37 @@ description: Manage the tools an LLM can use.
Tools allow the LLM to perform actions in your codebase. OpenCode comes with a set of built-in tools, but you can extend it with [custom tools](/docs/custom-tools) or [MCP servers](/docs/mcp-servers).
By default, all tools are **enabled** and don't need permission to run. But you can configure this and control the [permissions](/docs/permissions) through your config.
By default, all tools are **enabled** and don't need permission to run. You can control tool behavior through [permissions](/docs/permissions).
---
## Configure
You can configure tools globally or per agent. Agent-specific configs override global settings.
By default, all tools are set to `true`. To disable a tool, set it to `false`.
---
### Global
Disable or enable tools globally using the `tools` option.
Use the `permission` field to control tool behavior. You can allow, deny, or require approval for each tool.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"write": false,
"bash": false,
"webfetch": true
"permission": {
"edit": "deny",
"bash": "ask",
"webfetch": "allow"
}
}
```
You can also use wildcards to control multiple tools at once. For example, to disable all tools from an MCP server:
You can also use wildcards to control multiple tools at once. For example, to require approval for all tools from an MCP server:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"mymcp_*": false
"permission": {
"mymcp_*": "ask"
}
}
```
---
### Per agent
Override global tool settings for specific agents using the `tools` config in the agent definition.
```json title="opencode.json" {3-6,9-12}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"write": true,
"bash": true
},
"agent": {
"plan": {
"tools": {
"write": false,
"bash": false
}
}
}
}
```
For example, here the `plan` agent overrides the global config to disable `write` and `bash` tools.
You can also configure tools for agents in Markdown.
```markdown title="~/.config/opencode/agent/readonly.md"
---
description: Read-only analysis agent
mode: subagent
tools:
write: false
edit: false
bash: false
---
Analyze code without making any modifications.
```
[Learn more](/docs/agents#tools) about configuring tools per agent.
[Learn more](/docs/permissions) about configuring permissions.
---
@@ -101,8 +52,8 @@ Execute shell commands in your project environment.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"bash": true
"permission": {
"bash": "allow"
}
}
```
@@ -118,8 +69,8 @@ Modify existing files using exact string replacements.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"edit": true
"permission": {
"edit": "allow"
}
}
```
@@ -135,14 +86,18 @@ Create new files or overwrite existing ones.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"write": true
"permission": {
"edit": "allow"
}
}
```
Use this to allow the LLM to create new files. It will overwrite existing files if they already exist.
:::note
The `write` tool is controlled by the `edit` permission, which covers all file modifications (`edit`, `write`, `patch`, `multiedit`).
:::
---
### read
@@ -152,8 +107,8 @@ Read file contents from your codebase.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"read": true
"permission": {
"read": "allow"
}
}
```
@@ -169,8 +124,8 @@ Search file contents using regular expressions.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"grep": true
"permission": {
"grep": "allow"
}
}
```
@@ -186,8 +141,8 @@ Find files by pattern matching.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"glob": true
"permission": {
"glob": "allow"
}
}
```
@@ -203,8 +158,8 @@ List files and directories in a given path.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"list": true
"permission": {
"list": "allow"
}
}
```
@@ -224,8 +179,8 @@ This tool is only available when `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (or `OPEN
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"lsp": true
"permission": {
"lsp": "allow"
}
}
```
@@ -243,14 +198,18 @@ Apply patches to files.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"patch": true
"permission": {
"edit": "allow"
}
}
```
This tool applies patch files to your codebase. Useful for applying diffs and patches from various sources.
:::note
The `patch` tool is controlled by the `edit` permission, which covers all file modifications (`edit`, `write`, `patch`, `multiedit`).
:::
---
### skill
@@ -260,14 +219,12 @@ Load a [skill](/docs/skills) (a `SKILL.md` file) and return its content in the c
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"skill": true
"permission": {
"skill": "allow"
}
}
```
You can control approval prompts for loading skills via [permissions](/docs/permissions) using `permission.skill`.
---
### todowrite
@@ -277,8 +234,8 @@ Manage todo lists during coding sessions.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"todowrite": true
"permission": {
"todowrite": "allow"
}
}
```
@@ -286,7 +243,7 @@ Manage todo lists during coding sessions.
Creates and updates task lists to track progress during complex operations. The LLM uses this to organize multi-step tasks.
:::note
This tool is disabled for subagents by default, but you can enable it manually. [Learn more](/docs/agents/#tools)
This tool is disabled for subagents by default, but you can enable it manually. [Learn more](/docs/agents/#permissions)
:::
---
@@ -298,8 +255,8 @@ Read existing todo lists.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"todoread": true
"permission": {
"todoread": "allow"
}
}
```
@@ -307,7 +264,7 @@ Read existing todo lists.
Reads the current todo list state. Used by the LLM to track what tasks are pending or completed.
:::note
This tool is disabled for subagents by default, but you can enable it manually. [Learn more](/docs/agents/#tools)
This tool is disabled for subagents by default, but you can enable it manually. [Learn more](/docs/agents/#permissions)
:::
---
@@ -319,8 +276,8 @@ Fetch web content.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"webfetch": true
"permission": {
"webfetch": "allow"
}
}
```
@@ -329,6 +286,30 @@ Allows the LLM to fetch and read web pages. Useful for looking up documentation
---
### question
Ask the user questions during execution.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"question": "allow"
}
}
```
This tool allows the LLM to ask the user questions during a task. It's useful for:
- Gathering user preferences or requirements
- Clarifying ambiguous instructions
- Getting decisions on implementation choices
- Offering choices about what direction to take
Each question includes a header, the question text, and a list of options. Users can select from the provided options or type a custom answer. When there are multiple questions, users can navigate between them before submitting all answers.
---
## Custom tools
Custom tools let you define your own functions that the LLM can call. These are defined in your config file and can execute arbitrary code.

View File

@@ -4,7 +4,7 @@ import { $ } from "bun"
import { tmpdir } from "os"
import { join } from "path"
const FORK_REPO = "sst/zed-extensions"
const FORK_REPO = "anomalyco/zed-extensions"
const UPSTREAM_REPO = "zed-industries/extensions"
const EXTENSION_NAME = "opencode"

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.1.6",
"version": "1.1.7",
"publisher": "sst-dev",
"repository": {
"type": "git",