diff --git a/.github/last-synced-tag b/.github/last-synced-tag index ce81bd0b334..ed64856bbe5 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.0.203 +v1.0.204 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index c0e3a5deb15..44bfeb33661 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -64,7 +64,7 @@ jobs: Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage. - When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) + When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block. If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors. diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 00000000000..d41e8e60c50 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,29 @@ +name: "Auto-close stale issues" + +on: + schedule: + - cron: "30 1 * * *" # Daily at 1:30 AM + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/stale@v10 + with: + days-before-stale: 90 + days-before-close: 7 + stale-issue-label: "stale" + close-issue-message: | + [automated] Closing due to 90+ days of inactivity. + + Feel free to reopen if you still need this! + stale-issue-message: | + [automated] This issue has had no activity for 90 days. + + It will be closed in 7 days if there's no new activity. + remove-stale-when-updated: true + exempt-issue-labels: "pinned,security,feature-request,on-hold" + start-date: "2025-12-27" diff --git a/CONTEXT/PLAN-sidebar-reorder-collapsible-2025-12-26.md b/CONTEXT/PLAN-sidebar-reorder-collapsible-2025-12-26.md new file mode 100644 index 00000000000..c72dfa2054e --- /dev/null +++ b/CONTEXT/PLAN-sidebar-reorder-collapsible-2025-12-26.md @@ -0,0 +1,195 @@ +# Plan: Sidebar Section Reordering and Collapsible Sections + +**Created:** 2025-12-26 +**Status:** Completed +**Estimated Effort:** Small (1-2 hours) + +## Overview + +Reorganize the sidebar sections in the TUI session view and make all sections consistently collapsible. Currently, some sections have collapsible behavior while others do not, and the order doesn't match the desired hierarchy. + +## Current State Analysis + +### File Location +- **Primary file:** `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx` + +### Current Section Order (lines 118-372) +1. Session title (line 110-117) +2. **Context** (lines 118-125) +3. **MCP** (lines 126-186) - Has collapsible behavior +4. **LSP** (lines 187-228) - Has collapsible behavior (threshold: >2 items) +5. **Subagents** (lines 230-309) - Has collapsible behavior (threshold: >2 items) +6. **Todo** (lines 310-328) - Has collapsible behavior (threshold: >2 items) +7. **Modified Files** (lines 329-372) - Has collapsible behavior (threshold: >2 items) + +### Desired Section Order +1. Session title +2. **Context** - Make collapsible (new) +3. **Subagents** - Move up +4. **MCP** - Already collapsible +5. **LSP** - Already collapsible +6. **Modified Files** - Already collapsible (rename from "Modified Files" to "Changed Files" if desired) + +**Note:** Todo section appears to be omitted from the desired order. Clarify with user if Todo should be removed or repositioned. + +### Current Collapsible Implementation Pattern +Each collapsible section follows this pattern: +1. State tracked in `expanded` store (line 26-32) +2. Header with click handler to toggle (e.g., line 131) +3. Conditional arrow indicator `▼`/`▶` (e.g., line 134) +4. Show/hide content based on `expanded` state (e.g., line 147) +5. Threshold logic: only shows collapse controls when items > 2 + +## Technical Specifications + +### Expanded Store (line 26-32) +```typescript +const [expanded, setExpanded] = createStore({ + mcp: true, + diff: true, + todo: true, + lsp: true, + subagents: true, + context: true, // NEW: Add context to expanded store +}) +``` + +### Context Section - Current (lines 118-125) +```tsx + + + Context + + {context()?.tokens ?? 0} tokens + {context()?.percentage ?? 0}% used + {cost()} spent + +``` + +### Collapsible Section Template (based on MCP pattern) +```tsx + + setExpanded("context", !expanded.context)} + > + {expanded.context ? "▼" : "▶"} + + Context + + + {" "}({context()?.tokens ?? 0} tokens) + + + + + + {context()?.tokens ?? 0} tokens + {context()?.percentage ?? 0}% used + {cost()} spent + + +``` + +## Implementation Tasks + +### Phase 1: Add Context to Collapsible State +- [x] Add `context: true` to the `expanded` store initialization (line 31) + +### Phase 2: Make Context Section Collapsible +- [x] Wrap Context header in a clickable `` with `onMouseDown` handler +- [x] Add `▼`/`▶` indicator based on `expanded.context` state +- [x] Add collapsed summary showing token count in header +- [x] Wrap content in `` conditional + +### Phase 3: Reorder Sections +Move sections in JSX to match desired order: +- [x] Keep Session title first (lines 110-117) +- [x] Keep Context section second (lines 118-125) - now collapsible +- [x] Move Subagents section (lines 230-309) to third position +- [x] Keep MCP section fourth (lines 126-186) +- [x] Keep LSP section fifth (lines 187-228) +- [x] Move Modified Files section to last (lines 329-372) + +### Phase 4: Handle Todo Section +- [x] **Decision:** Todo section removed per user requirements (only Context, Subagents, MCP, LSP, Changed Files specified) + +### Phase 5: Verify Collapsible Behavior Consistency +- [x] Ensure all sections use same collapsible pattern +- [x] Context: Always show collapse control (no threshold, always has data) +- [x] All sections: Removed threshold logic for showing arrows - all sections now always show ▼/▶ + +### Phase 6: Testing +- [x] TypeScript compilation passes +- [ ] Test collapse/expand for Context section (manual verification needed) +- [ ] Verify section order displays correctly (manual verification needed) +- [ ] Test click handlers work for all sections (manual verification needed) +- [ ] Verify collapsed state summary displays correctly (manual verification needed) +- [ ] Test with empty sessions (no subagents, no MCP, etc.) (manual verification needed) + +## Code References + +### Internal Files +| File | Description | +|------|-------------| +| `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx` | Main sidebar component (426 lines) | +| `packages/opencode/src/cli/cmd/tui/context/theme.tsx` | Theme context for styling | +| `packages/opencode/src/cli/cmd/tui/ui/toast.tsx` | Toast notifications | + +### Key Line References (UPDATED after implementation) +| Line(s) | Description | +|---------|-------------| +| 26-33 | `expanded` store initialization (now includes `context`) | +| 110-117 | Session title section | +| 119-135 | Context section (now collapsible) | +| 137-214 | Subagents section (moved up) | +| 216-271 | MCP section | +| 273-313 | LSP section | +| 315-356 | Changed Files section (renamed from Modified Files) | + +## Validation Criteria + +### Functional Requirements +- [x] Context section collapses/expands on click +- [x] Collapsed Context shows token count in header +- [x] All sections appear in correct order: Context, Subagents, MCP, LSP, Changed Files +- [x] All collapsible sections show `▼` when expanded, `▶` when collapsed +- [x] State persists during session (store-based) + +### Visual Requirements +- [x] Collapse indicators align consistently across all sections +- [x] Collapsed headers show relevant summary info +- [ ] No layout shifts when toggling sections (manual verification needed) + +### Edge Cases +- [x] Empty subagents list doesn't show Subagents section (kept existing `` logic) +- [x] No MCP servers connected doesn't show MCP section (kept existing `` logic) +- [x] Zero tokens shows "0 tokens" correctly + +## Implementation Summary + +### Changes Made: +1. Added `context: true` to the expanded store +2. Made Context section fully collapsible with click handler and ▼/▶ indicator +3. Reordered sections to: Context → Subagents → MCP → LSP → Changed Files +4. Removed Todo section (not in user requirements) +5. Renamed "Modified Files" to "Changed Files" +6. Made all sections consistently collapsible (removed threshold logic for showing arrows) +7. Added collapsed state summaries: + - Context: shows token count + - Subagents: shows number of agent types + - MCP: shows active count and error count + - LSP: shows active count + - Changed Files: shows file count + +## Dependencies + +None - this is a self-contained UI change with no external dependencies. + +## Rollback Plan + +If issues arise, revert the single file change: +```bash +git checkout HEAD -- packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +``` diff --git a/README.md b/README.md index a8efc84f6b2..017b5200426 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ you can switch between these using the `Tab` key. - Asks permission before running bash commands - Ideal for exploring unfamiliar codebases or planning changes -Also, included is a **general** subagent for complex searches and multi-step tasks. +Also, included is a **general** subagent for complex searches and multistep tasks. This is used internally and can be invoked using `@general` in messages. Learn more about [agents](https://opencode.ai/docs/agents). @@ -233,7 +233,7 @@ If you are working on a project that's related to OpenCode and is using "opencod ### FAQ -#### How is this different than Claude Code? +#### How is this different from Claude Code? It's very similar to Claude Code in terms of capability. Here are the key differences: diff --git a/STATS.md b/STATS.md index d3c6f57ad40..41c93525478 100644 --- a/STATS.md +++ b/STATS.md @@ -181,3 +181,5 @@ | 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | | 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | | 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | +| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | +| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | diff --git a/bun.lock b/bun.lock index 6da0e950fec..3015c33511f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,6 @@ "": { "name": "opencode", "dependencies": { - "@ai-sdk/cerebras": "1.0.33", - "@ai-sdk/cohere": "2.0.21", - "@ai-sdk/deepinfra": "1.0.30", - "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/groq": "2.0.33", - "@ai-sdk/perplexity": "2.0.22", - "@ai-sdk/togetherai": "1.0.30", "@aws-sdk/client-s3": "3.933.0", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", @@ -29,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -78,7 +71,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -106,7 +99,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -133,7 +126,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -157,7 +150,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -181,7 +174,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", @@ -208,7 +201,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -237,7 +230,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -253,7 +246,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.203", + "version": "1.0.204", "bin": { "opencode": "./bin/opencode", }, @@ -264,14 +257,21 @@ "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/azure": "2.0.73", + "@ai-sdk/cerebras": "1.0.33", + "@ai-sdk/cohere": "2.0.21", + "@ai-sdk/deepinfra": "1.0.30", + "@ai-sdk/gateway": "2.0.23", "@ai-sdk/google": "2.0.44", "@ai-sdk/google-vertex": "3.0.81", + "@ai-sdk/groq": "2.0.33", "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", + "@ai-sdk/togetherai": "1.0.30", "@ai-sdk/xai": "2.0.42", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", @@ -293,6 +293,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", @@ -348,7 +349,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -368,7 +369,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.203", + "version": "1.0.204", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -379,7 +380,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -392,7 +393,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -427,7 +428,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "zod": "catalog:", }, @@ -438,7 +439,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1216,6 +1217,8 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -2160,6 +2163,8 @@ "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], + "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -2410,6 +2415,8 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -3216,6 +3223,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="], @@ -3820,6 +3829,8 @@ "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], diff --git a/flake.lock b/flake.lock index 4ff2c1d0e11..8bba6eeb3df 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766532406, - "narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=", + "lastModified": 1766747458, + "narHash": "sha256-m63jjuo/ygo8ztkCziYh5OOIbTSXUDkKbqw3Vuqu4a4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8142186f001295e5a3239f485c8a49bf2de2695a", + "rev": "c633f572eded8c4f3c75b8010129854ed404a6ce", "type": "github" }, "original": { diff --git a/install b/install index 1c12ac99afc..67db00e2308 100755 --- a/install +++ b/install @@ -155,8 +155,18 @@ if [ -z "$requested_version" ]; then exit 1 fi else + # Strip leading 'v' if present + requested_version="${requested_version#v}" url="https://github.com/Latitudes-Dev/shuvcode/releases/download/v${requested_version}/$filename" specific_version=$requested_version + + # Verify the release exists before downloading + http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/Latitudes-Dev/shuvcode/releases/tag/v${requested_version}") + if [ "$http_status" = "404" ]; then + echo -e "${RED}Error: Release v${requested_version} not found${NC}" + echo -e "${MUTED}Available releases: https://github.com/Latitudes-Dev/shuvcode/releases${NC}" + exit 1 + fi fi print_message() { diff --git a/nix/hashes.json b/nix/hashes.json index 66c0baaf791..f43b14684c3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME=" + "nodeModules": "sha256-lloUZt5mLyNWAcbQrJB4wGUKvKu24WFEhOLfZD5/FMg=" } diff --git a/package.json b/package.json index c677c72b73c..7dd9cdd2e5c 100644 --- a/package.json +++ b/package.json @@ -68,13 +68,6 @@ "turbo": "2.5.6" }, "dependencies": { - "@ai-sdk/cerebras": "1.0.33", - "@ai-sdk/cohere": "2.0.21", - "@ai-sdk/deepinfra": "1.0.30", - "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/groq": "2.0.33", - "@ai-sdk/perplexity": "2.0.22", - "@ai-sdk/togetherai": "1.0.30", "@aws-sdk/client-s3": "3.933.0", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 4a20d55a70d..5440503b1a1 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1 +1,2 @@ src/assets/theme.css +dev-dist/ diff --git a/packages/app/package.json b/packages/app/package.json index f1da1a1d2a1..88708a37c44 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.203", + "version": "1.0.204", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx new file mode 100644 index 00000000000..c29cd827e3b --- /dev/null +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -0,0 +1,91 @@ +import { Component, createMemo, createSignal, Show } from "solid-js" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +export const DialogSelectMcp: Component = () => { + const sync = useSync() + const sdk = useSDK() + const [loading, setLoading] = createSignal(null) + + const items = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setLoading(null) + } + + const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) + const totalCount = createMemo(() => items().length) + + return ( + + x?.name ?? ""} + items={items} + filterKeys={["name", "status"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + onSelect={(x) => { + if (x) toggle(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync.data.mcp[i.name] + const status = () => mcpStatus()?.status + const error = () => { + const s = mcpStatus() + return s?.status === "failed" ? s.error : undefined + } + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} + + connected + + + failed + + + needs auth + + + disabled + + + ... + +
+ + {error()} + +
+
e.stopPropagation()}> + toggle(i.name)} /> +
+
+ ) + }} +
+
+ ) +} diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx index 34ecdc8e503..e4c9e6e7f3e 100644 --- a/packages/app/src/components/header.tsx +++ b/packages/app/src/components/header.tsx @@ -190,6 +190,10 @@ export function Header(props: { shareURL = await globalSDK.client.session .share({ sessionID: session.id, directory: currentDirectory() }) .then((r) => r.data?.share?.url) + .catch((e) => { + console.error("Failed to share session", e) + return undefined + }) } return shareURL }, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index b493313b701..257f788e208 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -82,6 +82,37 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement + let scrollRef!: HTMLDivElement + + const scrollCursorIntoView = () => { + const container = scrollRef + const selection = window.getSelection() + if (!container || !selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return + + const rect = range.getBoundingClientRect() + if (!rect.height) return + + const containerRect = container.getBoundingClientRect() + const top = rect.top - containerRect.top + container.scrollTop + const bottom = rect.bottom - containerRect.top + container.scrollTop + const padding = 12 + + if (top < container.scrollTop + padding) { + container.scrollTop = Math.max(0, top - padding) + return + } + + if (bottom > container.scrollTop + container.clientHeight - padding) { + container.scrollTop = bottom - container.clientHeight + padding + } + } + + const queueScroll = () => { + requestAnimationFrame(scrollCursorIntoView) + } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -153,6 +184,7 @@ export const PromptInput: Component = (props) => { editorRef.focus() setCursorPosition(editorRef, length) setStore("applyingHistory", false) + queueScroll() }) } @@ -368,9 +400,23 @@ export const PromptInput: Component = (props) => { (currentParts) => { const domParts = parseFromDOM() const normalized = Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) return true + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" + if (!prevIsBr && !nextIsBr) return false + if (nextIsBr && !prevIsBr && prev) return false + return true + } if (node.nodeType !== Node.ELEMENT_NODE) return false - return (node as HTMLElement).dataset.type === "file" + const el = node as HTMLElement + if (el.dataset.type === "file") return true + return el.tagName === "BR" }) if (normalized && isPromptEqual(currentParts, domParts)) return @@ -383,7 +429,7 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" currentParts.forEach((part) => { if (part.type === "text") { - editorRef.appendChild(document.createTextNode(part.content)) + editorRef.appendChild(createTextFragment(part.content)) } else if (part.type === "file") { const pill = document.createElement("span") pill.textContent = part.content @@ -409,7 +455,7 @@ export const PromptInput: Component = (props) => { let buffer = "" const flushText = () => { - const content = buffer.replace(/\r\n?/g, "\n") + const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "") buffer = "" if (!content) return parts.push({ type: "text", content, start: position, end: position + content.length }) @@ -483,6 +529,7 @@ export const PromptInput: Component = (props) => { if (prompt.dirty()) { prompt.set(DEFAULT_PROMPT, 0) } + queueScroll() return } @@ -511,6 +558,7 @@ export const PromptInput: Component = (props) => { } prompt.set(rawParts, cursorPosition) + queueScroll() } const addPart = (part: ContentPart) => { @@ -540,9 +588,10 @@ export const PromptInput: Component = (props) => { const nodes = Array.from(editorRef.childNodes) for (const node of nodes) { - const length = node.textContent?.length ?? 0 + const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { if (edge === "start") range.setStart(node, remaining) @@ -550,7 +599,7 @@ export const PromptInput: Component = (props) => { return } - if (isFile && remaining <= length) { + if ((isFile || isBreak) && remaining <= length) { if (edge === "start" && remaining === 0) range.setStartBefore(node) if (edge === "start" && remaining > 0) range.setStartAfter(node) if (edge === "end" && remaining === 0) range.setEndBefore(node) @@ -576,11 +625,25 @@ export const PromptInput: Component = (props) => { selection.removeAllRanges() selection.addRange(range) } else if (part.type === "text") { - const textNode = document.createTextNode(part.content) const range = selection.getRangeAt(0) + const fragment = createTextFragment(part.content) + const last = fragment.lastChild range.deleteContents() - range.insertNode(textNode) - range.setStartAfter(textNode) + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } range.collapse(true) selection.removeAllRanges() selection.addRange(range) @@ -591,9 +654,11 @@ export const PromptInput: Component = (props) => { } const abort = () => - sdk.client.session.abort({ - sessionID: params.id!, - }) + sdk.client.session + .abort({ + sessionID: params.id!, + }) + .catch(() => {}) const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const text = prompt @@ -657,6 +722,24 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Backspace") { + const selection = window.getSelection() + if (selection && selection.isCollapsed) { + const node = selection.anchorNode + const offset = selection.anchorOffset + if (node && node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (/^\u200B+$/.test(text) && offset > 0) { + const range = document.createRange() + range.setStart(node, 0) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + } + if (event.key === "!" && store.mode === "normal") { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { @@ -697,7 +780,10 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const textLength = promptLength(prompt.current()) - const textContent = editorRef.textContent ?? "" + const textContent = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") const isEmpty = textContent.trim() === "" || textLength <= 1 const hasNewlines = textContent.includes("\n") const inHistory = store.historyIndex >= 0 @@ -813,12 +899,16 @@ export const PromptInput: Component = (props) => { const agent = local.agent.current()!.name if (isShellMode) { - sdk.client.session.shell({ - sessionID: existing.id, - agent, - model, - command: text, - }) + sdk.client.session + .shell({ + sessionID: existing.id, + agent, + model, + command: text, + }) + .catch((e) => { + console.error("Failed to send shell command", e) + }) return } @@ -827,13 +917,17 @@ export const PromptInput: Component = (props) => { const commandName = cmdName.slice(1) const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { - sdk.client.session.command({ - sessionID: existing.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - }) + sdk.client.session + .command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent, + model: `${model.providerID}/${model.modelID}`, + }) + .catch((e) => { + console.error("Failed to send command", e) + }) return } } @@ -859,13 +953,17 @@ export const PromptInput: Component = (props) => { model, }) - sdk.client.session.prompt({ - sessionID: existing.id, - agent, - model, - messageID, - parts: requestParts, - }) + sdk.client.session + .prompt({ + sessionID: existing.id, + agent, + model, + messageID, + parts: requestParts, + }) + .catch((e) => { + console.error("Failed to send prompt", e) + }) } return ( @@ -998,7 +1096,7 @@ export const PromptInput: Component = (props) => {
-
+
(scrollRef = el)}>
{ @@ -1139,23 +1237,56 @@ export const PromptInput: Component = (props) => { ) } +function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } else if (segments.length > 1) { + fragment.appendChild(document.createTextNode("\u200B")) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(parent) preCaretRange.setEnd(range.startContainer, range.startOffset) - return preCaretRange.toString().length + return getTextLength(preCaretRange.cloneContents()) } function setCursorPosition(parent: HTMLElement, position: number) { let remaining = position let node = parent.firstChild while (node) { - const length = node.textContent ? node.textContent.length : 0 + const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { const range = document.createRange() @@ -1167,10 +1298,24 @@ function setCursorPosition(parent: HTMLElement, position: number) { return } - if (isFile && remaining <= length) { + if ((isFile || isBreak) && remaining <= length) { const range = document.createRange() const selection = window.getSelection() - range.setStartAfter(node) + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isFile) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } range.collapse(true) selection?.removeAllRanges() selection?.addRange(range) diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx new file mode 100644 index 00000000000..98d6d6dfd76 --- /dev/null +++ b/packages/app/src/components/session-lsp-indicator.tsx @@ -0,0 +1,40 @@ +import { createMemo, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useSync } from "@/context/sync" +import { Tooltip } from "@opencode-ai/ui/tooltip" + +export function SessionLspIndicator() { + const sync = useSync() + + const lspStats = createMemo(() => { + const lsp = sync.data.lsp ?? [] + const connected = lsp.filter((s) => s.status === "connected").length + const hasError = lsp.some((s) => s.status === "error") + const total = lsp.length + return { connected, hasError, total } + }) + + const tooltipContent = createMemo(() => { + const lsp = sync.data.lsp ?? [] + if (lsp.length === 0) return "No LSP servers" + return lsp.map((s) => s.name).join(", ") + }) + + return ( + 0}> + +
+ 0, + }} + /> + {lspStats().connected} LSP +
+
+
+ ) +} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx new file mode 100644 index 00000000000..17a6f2e1af0 --- /dev/null +++ b/packages/app/src/components/session-mcp-indicator.tsx @@ -0,0 +1,36 @@ +import { createMemo, Show } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSync } from "@/context/sync" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" + +export function SessionMcpIndicator() { + const sync = useSync() + const dialog = useDialog() + + const mcpStats = createMemo(() => { + const mcp = sync.data.mcp ?? {} + const entries = Object.entries(mcp) + const enabled = entries.filter(([, status]) => status.status === "connected").length + const failed = entries.some(([, status]) => status.status === "failed") + const total = entries.length + return { enabled, failed, total } + }) + + return ( + 0}> + + + ) +} diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx new file mode 100644 index 00000000000..d8a88503f20 --- /dev/null +++ b/packages/app/src/components/status-bar.tsx @@ -0,0 +1,32 @@ +import { createMemo, Show, type ParentProps } from "solid-js" +import { usePlatform } from "@/context/platform" +import { useSync } from "@/context/sync" +import { useGlobalSync } from "@/context/global-sync" + +export function StatusBar(props: ParentProps) { + const platform = usePlatform() + const sync = useSync() + const globalSync = useGlobalSync() + + const directoryDisplay = createMemo(() => { + const directory = sync.data.path.directory || "" + const home = globalSync.data.path.home || "" + const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory + const branch = sync.data.vcs?.branch + return branch ? `${short}:${branch}` : short + }) + + return ( +
+
+ + v{platform.version} + + + {directoryDisplay()} + +
+
{props.children}
+
+ ) +} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index e143f701197..b103b589182 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -203,13 +203,15 @@ export const Terminal = (props: TerminalProps) => { ws.addEventListener("open", () => { if (!isMounted) return console.log("WebSocket connected") - sdk.client.pty.update({ - ptyID: local.pty.id, - size: { - cols: term.cols, - rows: term.rows, - }, - }) + sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { + cols: term.cols, + rows: term.rows, + }, + }) + .catch(() => {}) }) ws.addEventListener("message", (event) => { if (!isMounted) return diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index c0fc3ec6bfa..15fc3908170 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -14,6 +14,10 @@ import { type ProviderListResponse, type ProviderAuthResponse, type Command, + type McpStatus, + type LspStatus, + type VcsInfo, + type Permission, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -45,6 +49,14 @@ type State = { } changes: File[] node: FileNode[] + permission: { + [sessionID: string]: Permission[] + } + mcp: { + [name: string]: McpStatus + } + lsp: LspStatus[] + vcs: VcsInfo | undefined limit: number message: { [sessionID: string]: Message[] @@ -74,6 +86,7 @@ function createGlobalSync() { }) const children: Record>> = {} + const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set() function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { @@ -91,6 +104,10 @@ function createGlobalSync() { todo: {}, changes: [], node: [], + permission: {}, + mcp: {}, + lsp: [], + vcs: undefined, limit: 10, message: {}, part: {}, @@ -155,6 +172,18 @@ function createGlobalSync() { session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), + lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])), + vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), + permission: () => + sdk.permission.list().then((x) => { + const grouped: Record = {} + for (const perm of x.data ?? []) { + grouped[perm.sessionID] = grouped[perm.sessionID] ?? [] + grouped[perm.sessionID]!.push(perm) + } + setStore("permission", grouped) + }), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) @@ -301,6 +330,50 @@ function createGlobalSync() { } break } + case "vcs.branch.updated": { + setStore("vcs", { branch: event.properties.branch }) + break + } + case "permission.updated": { + const permissions = store.permission[event.properties.sessionID] + const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id) + if (!permissions) { + setStore("permission", event.properties.sessionID, [event.properties]) + } else { + const result = Binary.search(permissions, event.properties.id, (p) => p.id) + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + if (result.found) { + draft[result.index] = event.properties + return + } + draft.push(event.properties) + }), + ) + } + if (isNew) { + for (const listener of permissionListeners) { + listener({ directory, permission: event.properties }) + } + } + break + } + case "permission.replied": { + const permissions = store.permission[event.properties.sessionID] + if (!permissions) break + const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + if (!result.found) break + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } } }) @@ -373,6 +446,12 @@ function createGlobalSync() { project: { loadSessions, }, + permission: { + onUpdated(listener: (info: { directory: string; permission: Permission }) => void) { + permissionListeners.add(listener) + return () => permissionListeners.delete(listener) + }, + }, } } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 600a0e4b160..49217b82be8 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const list = async (path: string) => { - return sdk.client.file.list({ path: path + "/" }).then((x) => { - setStore( - "node", - produce((draft) => { - x.data!.forEach((node) => { - if (node.path in draft) return - draft[node.path] = node - }) - }), - ) - }) + return sdk.client.file + .list({ path: path + "/" }) + .then((x) => { + setStore( + "node", + produce((draft) => { + x.data!.forEach((node) => { + if (node.path in draft) return + draft[node.path] = node + }) + }), + ) + }) + .catch(() => {}) } const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 6f7c11dea8c..e9a07077cef 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), new() { - sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("all", [ - ...store.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("active", id) - }) + sdk.client.pty + .create({ title: `Terminal ${store.all.length + 1}` }) + .then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + .catch((e) => { + console.error("Failed to create terminal", e) + }) }, update(pty: Partial & { id: string }) { setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) + sdk.client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((e) => { + console.error("Failed to update terminal", e) + }) }, async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return + const clone = await sdk.client.pty + .create({ + title: pty.title, + }) + .catch((e) => { + console.error("Failed to clone terminal", e) + return undefined + }) + if (!clone?.data) return setStore("all", index, { ...pty, ...clone.data, @@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont setStore("active", previous?.id) } }) - await sdk.client.pty.remove({ ptyID: id }) + await sdk.client.pty.remove({ ptyID: id }).catch((e) => { + console.error("Failed to close terminal", e) + }) }, move(id: string, to: number) { const index = store.all.findIndex((f) => f.id === id) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index c909a373d56..04f90bdcbf6 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,6 +1,6 @@ import { createMemo, Show, type ParentProps } from "solid-js" import { useParams } from "@solidjs/router" -import { SDKProvider } from "@/context/sdk" +import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" @@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) { {iife(() => { const sync = useSync() + const sdk = useSDK() return ( - + { + sdk.client.permission.respond(input) + }} + > {props.children} ) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 33b66969734..069c3fbe5c9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, untrack, type JSX } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, untrack, type JSX } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" @@ -102,6 +102,39 @@ export default function Layout(props: ParentProps) { const currentSessionId = createMemo(() => currentSession()?.id) const otherSessions = createMemo(() => sessions().filter((s) => s.id !== currentSessionId())) + onMount(() => { + const unsub = globalSync.permission.onUpdated(({ directory, permission }) => { + const currentDir = params.dir ? base64Decode(params.dir) : undefined + const currentSession = params.id + if (directory === currentDir && permission.sessionID === currentSession) return + const [store] = globalSync.child(directory) + const session = store.session.find((s) => s.id === permission.sessionID) + if (directory === currentDir && session?.parentID === currentSession) return + const sessionTitle = session?.title ?? "New session" + const projectName = getFilename(directory) + showToast({ + persistent: true, + icon: "checklist", + title: "Permission required", + description: `${sessionTitle} in ${projectName} needs permission`, + actions: [ + { + label: "Go to session", + onClick: () => { + navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`) + }, + dismissAfter: true, + }, + { + label: "Dismiss", + onClick: "dismiss", + }, + ], + }) + }) + onCleanup(unsub) + }) + function flattenSessions(sessions: Session[]): Session[] { const childrenMap = new Map() for (const session of sessions) { @@ -124,6 +157,19 @@ export default function Layout(props: ParentProps) { return result } + function sortSessions(a: Session, b: Session) { + const now = Date.now() + const oneMinuteAgo = now - 60 * 1000 + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id.localeCompare(b.id) + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } + function scrollToSession(sessionId: string) { if (!scrollContainerRef) return const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) @@ -451,8 +497,20 @@ export default function Layout(props: ParentProps) { const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const hasPermissions = createMemo(() => { + const store = globalSync.child(props.project.worktree)[0] + const permissions = store.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + const childSessions = store.session.filter((s) => s.parentID === props.session.id) + for (const child of childSessions) { + const childPermissions = store.permission?.[child.id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) const isWorking = createMemo(() => { if (props.session.id === params.id) return false + if (hasPermissions()) return false const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) @@ -483,6 +541,9 @@ export default function Layout(props: ParentProps) { + +
+
@@ -611,7 +672,7 @@ export default function Layout(props: ParentProps) { closeProject(props.project.worktree)}> - Close Project + Close project diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c37bdae4aad..ab2321e461f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -51,6 +51,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSessionRename } from "@/components/dialog-session-rename" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage, ToolPart } from "@opencode-ai/sdk/v2" @@ -59,6 +60,9 @@ import { usePrompt } from "@/context/prompt" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { AskQuestionWizard, type AskQuestionQuestion, type AskQuestionAnswer } from "@/components/askquestion-wizard" +import { StatusBar } from "@/components/status-bar" +import { SessionMcpIndicator } from "@/components/session-mcp-indicator" +import { SessionLspIndicator } from "@/components/session-lsp-indicator" export default function Page() { const layout = useLayout() @@ -357,6 +361,15 @@ export default function Page() { slash: "model", onSelect: () => dialog.show(() => ), }, + { + id: "mcp.toggle", + title: "Toggle MCPs", + description: "Toggle MCPs", + category: "MCP", + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => ), + }, { id: "agent.cycle", title: "Cycle agent", @@ -1291,6 +1304,10 @@ export default function Page() {
+ + + +
) } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index a12dc87f24d..4474366b880 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts index a793b85962a..2f8781e9882 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/callback.ts @@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session" export async function GET(input: APIEvent) { const url = new URL(input.request.url) - const code = url.searchParams.get("code") - if (!code) throw new Error("No code found") - const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) - if (result.err) { - throw new Error(result.err.message) - } - const decoded = AuthClient.decode(result.tokens.access, {} as any) - if (decoded.err) throw new Error(decoded.err.message) - const session = await useAuthSession() - const id = decoded.subject.properties.accountID - await session.update((value) => { - return { - ...value, - account: { - ...value.account, - [id]: { - id, - email: decoded.subject.properties.email, + try { + const code = url.searchParams.get("code") + if (!code) throw new Error("No code found") + const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) + if (result.err) throw new Error(result.err.message) + const decoded = AuthClient.decode(result.tokens.access, {} as any) + if (decoded.err) throw new Error(decoded.err.message) + const session = await useAuthSession() + const id = decoded.subject.properties.accountID + await session.update((value) => { + return { + ...value, + account: { + ...value.account, + [id]: { + id, + email: decoded.subject.properties.email, + }, }, - }, - current: id, - } - }) - return redirect("/auth") + current: id, + } + }) + return redirect("/auth") + } catch (e: any) { + return new Response( + JSON.stringify({ + error: e.message, + cause: Object.fromEntries(url.searchParams.entries()), + }), + { status: 500 }, + ) + } } diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4f6d2717fb7..f74d28b2e32 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.203", + "version": "1.0.204", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 572a86ddd5e..57b004fb709 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.203", + "version": "1.0.204", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 742e0d567ce..082564b21ce 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -123,7 +123,11 @@ export default { }, }).then((x) => x.json())) as any subject = user.id.toString() - email = emails.find((x: any) => x.primary && x.verified)?.email + + const primaryEmail = emails.find((x: any) => x.primary) + if (!primaryEmail) throw new Error("No primary email found for GitHub user") + if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified") + email = primaryEmail.email } else if (response.provider === "google") { if (!response.id.email_verified) throw new Error("Google email not verified") subject = response.id.sub as string diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1b2869dd9ec..f2c7c7302f9 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4bdb5ce3886..23aa11091fb 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 123a2028c91..6d4f62dc2cb 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -10,6 +10,9 @@ export default defineConfig({ // // 1. prevent Vite from obscuring rust errors clearScreen: false, + build: { + sourcemap: true, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index a89e5df7ef7..e4a7f45beae 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.203", + "version": "1.0.204", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index e21818e4629..3e01e835339 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.203" +version = "1.0.204" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 160e78b35fd..44c6ef110ef 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.203", + "version": "1.0.204", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fda23d865b3..539f604a5d9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.203", + "version": "1.0.204", "name": "opencode", "type": "module", "private": true, @@ -52,14 +52,21 @@ "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/azure": "2.0.73", + "@ai-sdk/cerebras": "1.0.33", + "@ai-sdk/cohere": "2.0.21", + "@ai-sdk/deepinfra": "1.0.30", + "@ai-sdk/gateway": "2.0.23", "@ai-sdk/google": "2.0.44", "@ai-sdk/google-vertex": "3.0.81", + "@ai-sdk/groq": "2.0.33", "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", + "@ai-sdk/togetherai": "1.0.30", "@ai-sdk/xai": "2.0.42", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", @@ -81,6 +88,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", diff --git a/packages/opencode/src/agent/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt index f67aaa95bac..7e927b797ce 100644 --- a/packages/opencode/src/agent/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -22,7 +22,7 @@ Your output must be: - The title should NEVER include "summarizing" or "generating" when generating a title - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): +- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"): → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index c607e5f5bb7..060d0d5a156 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -5,6 +5,7 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) @@ -19,29 +20,16 @@ export const AcpCommand = cmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return yargs - .option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }) + return withNetworkOptions(yargs).option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const server = Server.listen({ - port: args.port, - hostname: args.hostname, - }) + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 773de8f7f0f..d6bd84798de 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -9,7 +9,9 @@ import * as github from "@actions/github" import type { Context } from "@actions/github/lib/context" import type { IssueCommentEvent, + IssuesEvent, PullRequestReviewCommentEvent, + WorkflowDispatchEvent, WorkflowRunEvent, PullRequestEvent, } from "@octokit/webhooks-types" @@ -132,7 +134,16 @@ type IssueQueryResponse = { const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_REACTION = "eyes" const WORKFLOW_FILE = ".github/workflows/opencode.yml" -const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const + +// Event categories for routing +// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments +// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only +const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const +const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const +const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const + +type UserEvent = (typeof USER_EVENTS)[number] +type RepoEvent = (typeof REPO_EVENTS)[number] // Parses GitHub remote URLs in various formats: // - https://github.com/owner/repo.git @@ -397,27 +408,38 @@ export const GithubRunCommand = cmd({ core.setFailed(`Unsupported event type: ${context.eventName}`) process.exit(1) } + + // Determine event category for routing + // USER_EVENTS: have actor, issueId, support reactions/comments + // REPO_EVENTS: no actor/issueId, output to logs/PR only + const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent) + const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent) const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName) + const isIssuesEvent = context.eventName === "issues" const isScheduleEvent = context.eventName === "schedule" + const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch" const { providerID, modelID } = normalizeModel() const runId = normalizeRunId() const share = normalizeShare() const oidcBaseUrl = normalizeOidcBaseUrl() const { owner, repo } = context.repo - // For schedule events, payload has no issue/comment data + // For repo events (schedule, workflow_dispatch), payload has no issue/comment data const payload = context.payload as | IssueCommentEvent + | IssuesEvent | PullRequestReviewCommentEvent + | WorkflowDispatchEvent | WorkflowRunEvent | PullRequestEvent const issueEvent = isIssueCommentEvent(payload) ? payload : undefined + // workflow_dispatch has an actor (the user who triggered it), schedule does not const actor = isScheduleEvent ? undefined : context.actor - const issueId = isScheduleEvent + const issueId = isRepoEvent ? undefined - : context.eventName === "issue_comment" - ? (payload as IssueCommentEvent).issue.number + : context.eventName === "issue_comment" || context.eventName === "issues" + ? (payload as IssueCommentEvent | IssuesEvent).issue.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` const shareBaseUrl = isMock ? "https://share.dev.shuv.ai" : "https://share.shuv.ai" @@ -462,8 +484,8 @@ export const GithubRunCommand = cmd({ if (!useGithubToken) { await configureGit(appToken) } - // Skip permission check for schedule events (no actor to check) - if (!isScheduleEvent) { + // Skip permission check and reactions for repo events (no actor to check, no issue to react to) + if (isUserEvent) { await assertPermissions() await addReaction(commentType) } @@ -480,25 +502,30 @@ export const GithubRunCommand = cmd({ })() console.log("opencode session", session.id) - // Handle 4 cases - // 1. Schedule (no issue/PR context) - // 2. Issue - // 3. Local PR - // 4. Fork PR - if (isScheduleEvent) { - // Schedule event - no issue/PR context, output goes to logs - const branch = await checkoutNewBranch("schedule") + // Handle event types: + // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only + // USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch + // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR + if (isRepoEvent) { + // Repo event - no issue/PR context, output goes to logs + if (isWorkflowDispatchEvent && actor) { + console.log(`Triggered by: ${actor}`) + } + const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule" + const branch = await checkoutNewBranch(branchPrefix) const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const response = await chat(userPrompt, promptFiles) const { dirty, uncommittedChanges } = await branchIsDirty(head) if (dirty) { const summary = await summarize(response) - await pushToNewBranch(summary, branch, uncommittedChanges, true) + // workflow_dispatch has an actor for co-author attribution, schedule does not + await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent) + const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow" const pr = await createPR( repoData.data.default_branch, branch, summary, - `${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`, + `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`, ) console.log(`Created PR #${pr}`) } else { @@ -573,7 +600,7 @@ export const GithubRunCommand = cmd({ } else if (e instanceof Error) { msg = e.message } - if (!isScheduleEvent) { + if (isUserEvent) { await createComment(`${msg}${footer()}`) await removeReaction(commentType) } @@ -628,9 +655,15 @@ export const GithubRunCommand = cmd({ } function isIssueCommentEvent( - event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent, + event: + | IssueCommentEvent + | IssuesEvent + | PullRequestReviewCommentEvent + | WorkflowDispatchEvent + | WorkflowRunEvent + | PullRequestEvent, ): event is IssueCommentEvent { - return "issue" in event + return "issue" in event && "comment" in event } function getReviewCommentContext() { @@ -652,10 +685,11 @@ export const GithubRunCommand = cmd({ async function getUserPrompt() { const customPrompt = process.env["PROMPT"] - // For schedule events, PROMPT is required since there's no comment to extract from - if (isScheduleEvent) { + // For repo events and issues events, PROMPT is required since there's no comment to extract from + if (isRepoEvent || isIssuesEvent) { if (!customPrompt) { - throw new Error("PROMPT input is required for scheduled events") + const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues" + throw new Error(`PROMPT input is required for ${eventType} events`) } return { userPrompt: customPrompt, promptFiles: [] } } @@ -942,7 +976,7 @@ export const GithubRunCommand = cmd({ await $`git config --local ${config} "${gitConfig}"` } - async function checkoutNewBranch(type: "issue" | "schedule") { + async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") { console.log("Checking out new branch...") const branch = generateBranchName(type) await $`git checkout -b ${branch}` @@ -971,16 +1005,16 @@ export const GithubRunCommand = cmd({ await $`git checkout -b ${localBranch} fork/${remoteBranch}` } - function generateBranchName(type: "issue" | "pr" | "schedule") { + function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") { const timestamp = new Date() .toISOString() .replace(/[:-]/g, "") .replace(/\.\d{3}Z/, "") .split("T") .join("") - if (type === "schedule") { + if (type === "schedule" || type === "dispatch") { const hex = crypto.randomUUID().slice(0, 6) - return `opencode/scheduled-${hex}-${timestamp}` + return `opencode/${type}-${hex}-${timestamp}` } return `opencode/${type}${issueId}-${timestamp}` } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 0236b3d64be..aa738962df6 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,29 +1,14 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => - yargs - .option("port", { - alias: ["p"], - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, - }) + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) const stop = async () => { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8b0b09b032b..aff3deae878 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -584,7 +584,7 @@ function App() { sdk.event.on(SessionApi.Event.Error.type, (evt) => { const error = evt.properties.error const message = (() => { - if (!error) return "An error occured" + if (!error) return "An error occurred" if (typeof error === "object") { const data = error.data diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 1217bb54ae0..cb7b5d282ee 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Locale } from "@/util/locale" import { Keybind } from "@/util/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" +import { useKV } from "../context/kv" import "opentui-spinner/solid" export function DialogSessionList() { @@ -16,6 +17,7 @@ export function DialogSessionList() { const { theme } = useTheme() const route = useRoute() const sdk = useSDK() + const kv = useKV() const [toDelete, setToDelete] = createSignal() @@ -45,7 +47,11 @@ export function DialogSessionList() { value: x.id, category, footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, + gutter: isWorking ? ( + [⋯]}> + + + ) : undefined, } }) .slice(0, 150) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d191c4976bb..6b1eb063cd2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -28,6 +28,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" +import { useKV } from "../../context/kv" // Regex to match optional whitespace followed by #L[-] line range syntax after a file reference // Only matches when followed by a space (confirming the line range is complete) @@ -129,6 +130,7 @@ export function Prompt(props: PromptProps) { const tall = createMemo(() => dimensions().height > 40) const wide = createMemo(() => dimensions().width > 120) const { theme, syntax } = useTheme() + const kv = useKV() function promptModelWarning() { toast.show({ @@ -1168,8 +1170,11 @@ export function Prompt(props: PromptProps) { justifyContent={status().type === "retry" ? "space-between" : "flex-start"} > - {/* @ts-ignore // SpinnerOptions doesn't support marginLeft */} - + + [⋯]}> + + + {(() => { const retry = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 71e3b444188..2588aef264c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -250,6 +250,7 @@ export function Session() { const [headerVisible, setHeaderVisible] = createSignal(kv.get("header_visible", true)) const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") + const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) // Initialize spinner style and interval from KV store const savedSpinnerStyle = kv.get("spinner_style", DEFAULT_SPINNER_KEY) @@ -888,6 +889,19 @@ export function Session() { dialog.clear() }, }, + { + title: animationsEnabled() ? "Disable animations" : "Enable animations", + value: "session.toggle.animations", + category: "Session", + onSelect: (dialog) => { + setAnimationsEnabled((prev) => { + const next = !prev + kv.set("animations_enabled", next) + return next + }) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 9883bdb3f60..8cc52ab8c2c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,7 +11,6 @@ import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { getSpinnerFrame } from "../../util/spinners" import { useToast } from "../../ui/toast" -import { TodoItem } from "../../component/todo-item" export function Sidebar(props: { sessionID: string; width: number }) { const sync = useSync() @@ -20,13 +19,13 @@ export function Sidebar(props: { sessionID: string; width: number }) { const toast = useToast() const session = createMemo(() => sync.session.get(props.sessionID)) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) - const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) + const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) const [expanded, setExpanded] = createStore({ + context: true, mcp: true, diff: true, - todo: true, lsp: true, subagents: true, }) @@ -115,24 +114,108 @@ export function Sidebar(props: { sessionID: string; width: number }) { {session()?.share?.url} +{/* Context Section */} - - Context - - {context()?.tokens ?? 0} tokens - {context()?.percentage ?? 0}% used - {cost()} spent + setExpanded("context", !expanded.context)}> + {expanded.context ? "▼" : "▶"} + + Context + + ({context()?.tokens ?? 0} tokens) + + + + + {context()?.tokens ?? 0} tokens + {context()?.percentage ?? 0}% used + {cost()} spent + + + {/* Subagents Section */} + 0}> + + setExpanded("subagents", !expanded.subagents)}> + {expanded.subagents ? "▼" : "▶"} + + Subagents + + ({subagentGroups().length} types) + + + + + + {([agentName, parts]) => { + const hasActive = () => + parts.some((p) => p.state.status === "running" || p.state.status === "pending") + return ( + + + + • + + + {agentName} + + + + {(part) => { + const isActive = () => part.state.status === "running" || part.state.status === "pending" + const isError = () => part.state.status === "error" + const input = part.state.input as Record + const description = (input?.description as string) ?? "" + + // Get subagent session ID from metadata, not part.sessionID (which is the parent) + const metadata = + part.state.status === "completed" + ? part.state.metadata + : ((part.state as { metadata?: Record }).metadata ?? {}) + const subagentSessionId = (metadata?.sessionId as string) ?? undefined + + return ( + { + if (subagentSessionId) { + try { + await sync.session.sync(subagentSessionId) + route.navigate({ type: "session", sessionID: subagentSessionId }) + } catch (e) { + console.error("Failed to sync subagent session:", e) + toast.show({ + message: `Session not found`, + variant: "error", + }) + } + } + }} + > + + {isActive() ? getSpinnerFrame() : isError() ? "✗" : "✓"} + + + {description} + + + ) + }} + + + ) + }} + + + + + + {/* MCP Section */} 0}> - mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)} - > - 2}> - {expanded.mcp ? "▼" : "▶"} - + setExpanded("mcp", !expanded.mcp)}> + {expanded.mcp ? "▼" : "▶"} MCP @@ -144,7 +227,7 @@ export function Sidebar(props: { sessionID: string; width: number }) { - + {([key, item]) => ( @@ -184,20 +267,19 @@ export function Sidebar(props: { sessionID: string; width: number }) { + + {/* LSP Section */} - sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)} - > - 2}> - {expanded.lsp ? "▼" : "▶"} - + setExpanded("lsp", !expanded.lsp)}> + {expanded.lsp ? "▼" : "▶"} LSP + + ({sync.data.lsp.length} active) + - + {sync.data.config.lsp === false @@ -227,120 +309,20 @@ export function Sidebar(props: { sessionID: string; width: number }) { - 0}> - - subagentGroups().length > 2 && setExpanded("subagents", !expanded.subagents)} - > - 2}> - {expanded.subagents ? "▼" : "▶"} - - - Subagents - - - - - {([agentName, parts]) => { - const hasActive = () => - parts.some((p) => p.state.status === "running" || p.state.status === "pending") - return ( - - - - • - - - {agentName} - - - - {(part) => { - const isActive = () => part.state.status === "running" || part.state.status === "pending" - const isError = () => part.state.status === "error" - const input = part.state.input as Record - const description = (input?.description as string) ?? "" - // Get subagent session ID from metadata, not part.sessionID (which is the parent) - const metadata = - part.state.status === "completed" - ? part.state.metadata - : ((part.state as { metadata?: Record }).metadata ?? {}) - const subagentSessionId = (metadata?.sessionId as string) ?? undefined - - return ( - { - if (subagentSessionId) { - try { - await sync.session.sync(subagentSessionId) - route.navigate({ type: "session", sessionID: subagentSessionId }) - } catch (e) { - console.error("Failed to sync subagent session:", e) - toast.show({ - message: `Session not found`, - variant: "error", - }) - } - } - }} - > - - {isActive() ? getSpinnerFrame() : isError() ? "✗" : "✓"} - - - {description} - - - ) - }} - - - ) - }} - - - - - 0 && todo().some((t) => t.status !== "completed")}> - - todo().length > 2 && setExpanded("todo", !expanded.todo)} - > - 2}> - {expanded.todo ? "▼" : "▶"} - - - Todo - - - - {(todo) => } - - - + {/* Changed Files Section */} 0}> - diff().length > 2 && setExpanded("diff", !expanded.diff)} - > - 2}> - {expanded.diff ? "▼" : "▶"} - + setExpanded("diff", !expanded.diff)}> + {expanded.diff ? "▼" : "▶"} - Modified Files + Changed Files + + ({diff().length} files) + - + {(item) => { const file = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts index fa679529890..ef359e6f40e 100644 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -3,31 +3,19 @@ import { Instance } from "@/project/instance" import path from "path" import { Server } from "@/server/server" import { upgrade } from "@/cli/upgrade" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" export const TuiSpawnCommand = cmd({ command: "spawn [project]", builder: (yargs) => - yargs - .positional("project", { - type: "string", - describe: "path to start opencode in", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + withNetworkOptions(yargs).positional("project", { + type: "string", + describe: "path to start opencode in", + }), handler: async (args) => { upgrade() - const server = Server.listen({ - port: args.port, - hostname: "127.0.0.1", - }) + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) const bin = process.execPath const cmd = [] let cwd = process.cwd() diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index dda313e19bc..5f07dc83b9e 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -6,6 +6,7 @@ import path from "path" import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" declare global { const OPENCODE_WORKER_PATH: string @@ -15,7 +16,7 @@ export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", builder: (yargs) => - yargs + withNetworkOptions(yargs) .positional("project", { type: "string", describe: "path to start opencode in", @@ -36,23 +37,12 @@ export const TuiThreadCommand = cmd({ describe: "session id to continue", }) .option("prompt", { - alias: ["p"], type: "string", describe: "prompt to use", }) .option("agent", { type: "string", describe: "agent to use", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", }), handler: async (args) => { // Resolve relative paths against PWD to preserve behavior when using --cwd flag @@ -87,10 +77,8 @@ export const TuiThreadCommand = cmd({ process.on("unhandledRejection", (e) => { Log.Default.error(e) }) - const server = await client.call("server", { - port: args.port, - hostname: args.hostname, - }) + const opts = await resolveNetworkOptions(args) + const server = await client.call("server", opts) const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 76f78f3faa8..3ffc45ae884 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -30,7 +30,7 @@ process.on("uncaughtException", (e) => { let server: Bun.Server export const rpc = { - async server(input: { port: number; hostname: string }) { + async server(input: { port: number; hostname: string; mdns?: boolean }) { if (server) await server.stop(true) try { server = Server.listen(input) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 3d3036b1b07..fb32472d7ab 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,7 @@ import { Server } from "../../server/server" import { UI } from "../ui" import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" import open from "open" import { networkInterfaces } from "os" @@ -28,32 +29,16 @@ function getNetworkIPs() { export const WebCommand = cmd({ command: "web", - builder: (yargs) => - yargs - .option("port", { - alias: ["p"], - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, - }) + const opts = await resolveNetworkOptions(args) + const server = Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) UI.empty() - if (hostname === "0.0.0.0") { + if (opts.hostname === "0.0.0.0") { // Show localhost for local access const localhostUrl = `http://localhost:${server.port}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) @@ -70,6 +55,10 @@ export const WebCommand = cmd({ } } + if (opts.mdns) { + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + } + // Open localhost in browser open(localhostUrl.toString()).catch(() => {}) } else { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts new file mode 100644 index 00000000000..397f2ba3e20 --- /dev/null +++ b/packages/opencode/src/cli/network.ts @@ -0,0 +1,43 @@ +import type { Argv, InferredOptionTypes } from "yargs" +import { Config } from "../config/config" + +const options = { + port: { + type: "number" as const, + describe: "port to listen on", + default: 0, + }, + hostname: { + type: "string" as const, + describe: "hostname to listen on", + default: "127.0.0.1", + }, + mdns: { + type: "boolean" as const, + describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", + default: false, + }, +} + +export type NetworkOptions = InferredOptionTypes + +export function withNetworkOptions(yargs: Argv) { + return yargs.options(options) +} + +export async function resolveNetworkOptions(args: NetworkOptions) { + const config = await Config.global() + const portExplicitlySet = process.argv.includes("--port") + const hostnameExplicitlySet = process.argv.includes("--hostname") + const mdnsExplicitlySet = process.argv.includes("--mdns") + + const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) + const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) + const hostname = hostnameExplicitlySet + ? args.hostname + : mdns && !config?.server?.hostname + ? "0.0.0.0" + : (config?.server?.hostname ?? args.hostname) + + return { hostname, port, mdns } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 77b47f95aec..7c10b7ca073 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -134,6 +134,14 @@ export namespace Config { if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) + // Apply flag overrides for compaction settings + if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { + result.compaction = { ...result.compaction, auto: false } + } + if (Flag.OPENCODE_DISABLE_PRUNE) { + result.compaction = { ...result.compaction, prune: false } + } + return { config: result, directories, @@ -590,6 +598,17 @@ export namespace Config { ), }) + export const Server = z + .object({ + port: z.number().int().positive().optional().describe("Port to listen on"), + hostname: z.string().optional().describe("Hostname to listen on"), + mdns: z.boolean().optional().describe("Enable mDNS service discovery"), + }) + .strict() + .meta({ + ref: "ServerConfig", + }) + export const Layout = z.enum(["auto", "stretch"]).meta({ ref: "LayoutConfig", }) @@ -636,7 +655,9 @@ export namespace Config { $schema: z.string().optional().describe("JSON schema reference for configuration validation"), theme: z.string().optional().describe("Theme name to use for the interface"), keybinds: Keybinds.optional().describe("Custom keybind configurations"), + logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), + server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z .record(z.string(), Command) .optional() @@ -782,6 +803,12 @@ export namespace Config { }) .optional() .describe("IDE integration settings"), + compaction: z + .object({ + auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), + prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + }) + .optional(), experimental: z .object({ hook: z diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 7d6929fd84d..1194d7a0326 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -7,6 +7,7 @@ import path from "path" import fs from "fs" import ignore from "ignore" import { Log } from "../util/log" +import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" @@ -273,6 +274,13 @@ export namespace File { using _ = log.time("read", { file }) const project = Instance.project const full = path.join(Instance.directory, file) + + // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. + // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + if (!Filesystem.contains(Instance.directory, full)) { + throw new Error(`Access denied: path escapes project directory`) + } + const bunFile = Bun.file(full) if (!(await bunFile.exists())) { @@ -353,6 +361,13 @@ export namespace File { ignored = ig.ignores.bind(ig) } const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory + + // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. + // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + if (!Filesystem.contains(Instance.directory, resolved)) { + throw new Error(`Access denied: path escapes project directory`) + } + const nodes: Node[] = [] for (const entry of await fs.promises .readdir(resolved, { diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 954940f8db2..90c48b05c2a 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -313,3 +313,12 @@ export const gleam: Info = { return Bun.which("gleam") !== null }, } + +export const shfmt: Info = { + name: "shfmt", + command: ["shfmt", "-w", "$FILE"], + extensions: [".sh", ".bash"], + async enabled() { + return Bun.which("shfmt") !== null + }, +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index ae80f567068..f3a5ac4eb23 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -87,6 +87,17 @@ export namespace Permission { 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"] title: Info["title"] diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d86fe90222d..407f7351b5b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -214,7 +214,7 @@ export namespace ProviderTransform { const id = model.id.toLowerCase() if (id.includes("qwen")) return 0.55 if (id.includes("claude")) return undefined - if (id.includes("gemini-3-pro")) return 1.0 + if (id.includes("gemini")) return 1.0 if (id.includes("glm-4.6")) return 1.0 if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 @@ -232,12 +232,14 @@ export namespace ProviderTransform { if (id.includes("m2.1")) return 0.9 return 0.95 } + if (id.includes("gemini")) return 0.95 return undefined } export function topK(model: Provider.Model) { const id = model.id.toLowerCase() if (id.includes("minimax-m2")) return 20 + if (id.includes("gemini")) return 64 return undefined } diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts new file mode 100644 index 00000000000..45e61d361ac --- /dev/null +++ b/packages/opencode/src/server/mdns.ts @@ -0,0 +1,57 @@ +import { Log } from "@/util/log" +import Bonjour from "bonjour-service" + +const log = Log.create({ service: "mdns" }) + +export namespace MDNS { + let bonjour: Bonjour | undefined + let currentPort: number | undefined + + export function publish(port: number, name = "opencode") { + if (currentPort === port) return + if (bonjour) unpublish() + + try { + bonjour = new Bonjour() + const service = bonjour.publish({ + name, + type: "http", + port, + txt: { path: "/" }, + }) + + service.on("up", () => { + log.info("mDNS service published", { name, port }) + }) + + service.on("error", (err) => { + log.error("mDNS service error", { error: err }) + }) + + currentPort = port + } catch (err) { + log.error("mDNS publish failed", { error: err }) + if (bonjour) { + try { + bonjour.destroy() + } catch {} + } + bonjour = undefined + currentPort = undefined + } + } + + export function unpublish() { + if (bonjour) { + try { + bonjour.unpublishAll() + bonjour.destroy() + } catch (err) { + log.error("mDNS unpublish failed", { error: err }) + } + bonjour = undefined + currentPort = undefined + log.info("mDNS service unpublished") + } + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 43575e0d9dc..936701d2217 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -48,10 +48,12 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" import { SessionStatus } from "@/session/status" import { upgradeWebSocket, websocket, serveStatic } from "hono/bun" +import type { BunWebSocketData } from "hono/bun" import { errors } from "./error" import { Pty } from "@/pty" import { Installation } from "@/installation" import { AskQuestion } from "@/askquestion" +import { MDNS } from "./mdns" import fs from "fs" import path from "path" @@ -1135,6 +1137,8 @@ export namespace Server { async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") + const session = await Session.get(sessionID) + await SessionRevert.cleanup(session) const msgs = await Session.messages({ sessionID }) let currentAgent = await Agent.defaultAgent() for (let i = msgs.length - 1; i >= 0; i--) { @@ -1651,6 +1655,28 @@ export namespace Server { return c.json(true) }, ) + .get( + "/permission", + describeRoute({ + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + operationId: "permission.list", + responses: { + 200: { + description: "List of pending permissions", + content: { + "application/json": { + schema: resolver(Permission.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const permissions = Permission.list() + return c.json(permissions) + }, + ) .get( "/command", describeRoute({ @@ -2811,7 +2837,8 @@ export namespace Server { ) export async function openapi() { - const result = await generateSpecs(App(), { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await generateSpecs(App() as any, { documentation: { info: { title: "opencode", @@ -2824,20 +2851,41 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string }) { + export function listen(opts: { port: number; hostname: string; mdns?: boolean }) { const args = { hostname: opts.hostname, idleTimeout: 0, fetch: App().fetch, websocket: websocket, } as const - if (opts.port === 0) { + const tryServe = (port: number) => { try { - return Bun.serve({ ...args, port: 4096 }) + return Bun.serve({ ...args, port }) } catch { - // port 4096 not available, fall through to use port 0 + return undefined } } - return Bun.serve({ ...args, port: opts.port }) + const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) + if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + + const shouldPublishMDNS = + opts.mdns && + server.port && + opts.hostname !== "127.0.0.1" && + opts.hostname !== "localhost" && + opts.hostname !== "::1" + if (shouldPublishMDNS) { + MDNS.publish(server.port!) + } else if (opts.mdns) { + log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") + } + + const originalStop = server.stop.bind(server) + server.stop = async (closeActiveConnections?: boolean) => { + if (shouldPublishMDNS) MDNS.unpublish() + return originalStop(closeActiveConnections) + } + + return server } } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 1a50c36f3a4..fc90aa45f9b 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -7,13 +7,13 @@ import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" import z from "zod" import { SessionPrompt } from "./prompt" -import { Flag } from "../flag/flag" import { Token } from "../util/token" import { Log } from "../util/log" import { SessionProcessor } from "./processor" import { fn } from "@/util/fn" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" +import { Config } from "@/config/config" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -27,8 +27,9 @@ export namespace SessionCompaction { ), } - export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { - if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false + export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + const config = await Config.get() + if (config.compaction?.auto === false) return false const context = input.model.limit.context if (context === 0) return false const count = input.tokens.input + input.tokens.cache.read + input.tokens.output @@ -46,7 +47,8 @@ export namespace SessionCompaction { // calls. then erases output of previous tool calls. idea is to throw away old // tool calls that are no longer relevant. export async function prune(input: { sessionID: string }) { - if (Flag.OPENCODE_DISABLE_PRUNE) return + const config = await Config.get() + if (config.compaction?.prune === false) return log.info("pruning") const msgs = await Session.messages({ sessionID: input.sessionID }) let total = 0 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0b1341a9966..f201d9b06dd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -482,7 +482,7 @@ export namespace SessionPrompt { if ( lastFinished && lastFinished.summary !== true && - SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }) + (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) ) { await SessionCompaction.create({ sessionID, diff --git a/packages/opencode/src/session/prompt/copilot-gpt-5.txt b/packages/opencode/src/session/prompt/copilot-gpt-5.txt index 81594301944..f8e3e6b8c98 100644 --- a/packages/opencode/src/session/prompt/copilot-gpt-5.txt +++ b/packages/opencode/src/session/prompt/copilot-gpt-5.txt @@ -129,7 +129,7 @@ Tools can be disabled by the user. You may see tools used previously in the conv Use proper Markdown formatting in your answers. When referring to a filename or symbol in the user's workspace, wrap it in backticks. When sharing setup or run steps for the user to execute, render commands in fenced code blocks with an appropriate language tag (`bash`, `sh`, `powershell`, `python`, etc.). Keep one command per line; avoid prose-only representations of commands. -Keep responses conversational and fun—use a brief, friendly preamble that acknowledges the goal and states what you're about to do next. Avoid literal scaffold labels like "Plan:", "Task receipt:", or "Actions:"; instead, use short paragraphs and, when helpful, concise bullet lists. Do not start with filler acknowledgements (e.g., "Sounds good", "Great", "Okay, I will…"). For multi-step tasks, maintain a lightweight checklist implicitly and weave progress into your narration. +Keep responses conversational and fun—use a brief, friendly preamble that acknowledges the goal and states what you're about to do next. Avoid literal scaffold labels like "Plan:", "Task receipt:", or "Actions:"; instead, use short paragraphs and, when helpful, concise bullet lists. Do not start with filler acknowledgements (e.g., "Sounds good", "Great", "Okay, I will…"). For multistep tasks, maintain a lightweight checklist implicitly and weave progress into your narration. For section headers in your response, use level-2 Markdown headings (`##`) for top-level sections and level-3 (`###`) for subsections. Choose titles dynamically to match the task and content. Do not hard-code fixed section names; create only the sections that make sense and only when they have non-empty content. Keep headings short and descriptive (e.g., "actions taken", "files changed", "how to run", "performance", "notes"), and order them naturally (actions > artifacts > how to run > performance > notes) when applicable. You may add a tasteful emoji to a heading when it improves scannability; keep it minimal and professional. Headings must start at the beginning of the line with `## ` or `### `, have a blank line before and after, and must not be inside lists, block quotes, or code fences. When listing files created/edited, include a one-line purpose for each file when helpful. In performance sections, base any metrics on actual runs from this session; note the hardware/OS context and mark estimates clearly—never fabricate numbers. In "Try it" sections, keep commands copyable; comments starting with `#` are okay, but put each command on its own line. If platform-specific acceleration applies, include an optional speed-up fenced block with commands. Close with a concise completion summary describing what changed and how it was verified (build/tests/linters), plus any follow-ups. diff --git a/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt b/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt index a5c2f267e07..28f1e629dbe 100644 --- a/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt +++ b/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt @@ -1,7 +1,7 @@ # Plan Mode - System Reminder -Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received. +Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. --- diff --git a/packages/opencode/src/session/prompt/polaris.txt b/packages/opencode/src/session/prompt/polaris.txt deleted file mode 100644 index f90761890da..00000000000 --- a/packages/opencode/src/session/prompt/polaris.txt +++ /dev/null @@ -1,107 +0,0 @@ -You are OpenCode, the best coding agent on the planet. - -You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. - -IMPORTANT: Do not guess arbitrary URLs. Only provide URLs you are confident are correct and directly helpful for programming (for example, well-known official documentation). Prefer URLs provided by the user in their messages or local files. - -If the user asks for help or wants to give feedback inform them of the following: -- ctrl+p to list available actions -- To give feedback, users should report the issue at - https://github.com/sst/opencode - -When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs. - -When the user asks in second person (eg. "are you able...", "can you do..."), treat it as a request to help. Briefly confirm your capability and, when appropriate, immediately start performing the requested task or provide a concrete, useful answer instead of replying with only "yes" or "no". - -# Tone and style -- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. -- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. -- Do not create new files unless necessary for achieving your goal or explicitly requested. Prefer editing an existing file when possible. This includes markdown files. - -# Professional objectivity -Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. - -# Task Management -You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools frequently for multi-step or non-trivial tasks to give the user visibility into your progress. -These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. - -Prefer marking todos as completed soon after you finish each task, rather than delaying without reason. - -Examples: - - -user: Run the build and fix any type errors -assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: -- Run the build -- Fix any type errors - -I'm now going to run the build using Bash. - -Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. - -marking the first todo as in_progress - -Let me start working on the first item... - -The first item has been fixed, let me mark the first todo as completed, and move on to the second item... -.. -.. - -In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. - - -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. -Adding the following todos to the todo list: -1. Research existing metrics tracking in the codebase -2. Design the metrics collection system -3. Implement core metrics tracking functionality -4. Create export functionality for different formats - -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. - -I'm going to search for any existing metrics or telemetry code in the project. - -I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... - -[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] - - - -# Doing tasks -The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: -- -- Use the TodoWrite tool to plan the task if required - -- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. - - -# Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. -- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. - -- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. -- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. -- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. -- Generally use the Task tool for broader or multi-file exploration; direct reads and searches are fine for specific, simple queries. - -user: Where are errors from the client handled? -assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly] - - -user: What is the codebase structure? -assistant: [Uses the Task tool] - - -Prefer using the TodoWrite tool to plan and track tasks when there are multiple steps or files involved. - -# Code References - -When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. - - -user: Where are errors from the client handled? -assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. - diff --git a/packages/opencode/src/session/prompt/qwen.txt b/packages/opencode/src/session/prompt/qwen.txt index a34fdb01a05..d88d9d063ba 100644 --- a/packages/opencode/src/session/prompt/qwen.txt +++ b/packages/opencode/src/session/prompt/qwen.txt @@ -84,7 +84,7 @@ The user will primarily request you perform software engineering tasks. This inc - Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. - Implement the solution using all tools available to you - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. +- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 30094388168..429e696db3b 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -10,7 +10,6 @@ import os from "os" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt" -import PROMPT_POLARIS from "./prompt/polaris.txt" import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" @@ -30,7 +29,6 @@ export namespace SystemPrompt { return [PROMPT_BEAST] if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI] if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC] - if (model.api.id.includes("polaris-alpha")) return [PROMPT_POLARIS] return [PROMPT_ANTHROPIC_WITHOUT_TODO] } diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index a81deb62bf2..c31263c04eb 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -1,6 +1,6 @@ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. -All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. +All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. @@ -11,10 +11,10 @@ Before executing the command, please follow these steps: - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory 2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - Examples of proper quoting: - - cd "/Users/name/My Documents" (correct) - - cd /Users/name/My Documents (incorrect - will fail) + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) - python "/path/with spaces/script.py" (correct) - python /path/with spaces/script.py (incorrect - will fail) - After ensuring proper quoting, execute the command. @@ -22,11 +22,11 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). + - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds 30000 characters, output will be truncated before being returned to you. - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. - + - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) - Content search: Use Grep (NOT grep or rg) @@ -39,9 +39,9 @@ Usage notes: - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead. - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it. + - AVOID using `cd && `. Use the `workdir` parameter to change directories instead. - pytest /foo/bar/tests + Use workdir="/foo/bar" with command: pytest tests cd /foo/bar && pytest tests @@ -53,7 +53,7 @@ Only create commits when requested by the user. If unclear, ask first. When the Git Safety Protocol: - NEVER update the git config -- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them +- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it - NEVER run force push to main/master, warn the user if they request it - Avoid git commit --amend. ONLY use --amend when ALL conditions are met: @@ -70,7 +70,7 @@ Git Safety Protocol: - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. 2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). - - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files + - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" - Ensure it accurately reflects the changes and their purpose 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands: diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 07b2f4d10dc..129a3b811cd 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -187,8 +187,8 @@ export const EditTool = Tool.define("edit", { const diagnostics = await LSP.diagnostics() const normalizedFilePath = Filesystem.normalizePath(filePath) const issues = diagnostics[normalizedFilePath] ?? [] - if (issues.length > 0) { - const errors = issues.filter((item) => item.severity === 1) + const errors = issues.filter((item) => item.severity === 1) + if (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index 6067ef27b9d..adf583695ae 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -5,4 +5,4 @@ - Returns file paths and line numbers with at least one match sorted by modification time - Use this tool when you need to find files containing specific patterns - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. -- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/packages/opencode/src/tool/lsp-diagnostics.ts b/packages/opencode/src/tool/lsp-diagnostics.ts deleted file mode 100644 index 18a6868b677..00000000000 --- a/packages/opencode/src/tool/lsp-diagnostics.ts +++ /dev/null @@ -1,26 +0,0 @@ -import z from "zod" -import { Tool } from "./tool" -import path from "path" -import { LSP } from "../lsp" -import DESCRIPTION from "./lsp-diagnostics.txt" -import { Instance } from "../project/instance" - -export const LspDiagnosticTool = Tool.define("lsp_diagnostics", { - description: DESCRIPTION, - parameters: z.object({ - path: z.string().describe("The path to the file to get diagnostics."), - }), - execute: async (args) => { - const normalized = path.isAbsolute(args.path) ? args.path : path.join(Instance.directory, args.path) - await LSP.touchFile(normalized, true) - const diagnostics = await LSP.diagnostics() - const file = diagnostics[normalized] - return { - title: path.relative(Instance.worktree, normalized), - metadata: { - diagnostics, - }, - output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found", - } - }, -}) diff --git a/packages/opencode/src/tool/lsp-diagnostics.txt b/packages/opencode/src/tool/lsp-diagnostics.txt deleted file mode 100644 index 88a50f6347a..00000000000 --- a/packages/opencode/src/tool/lsp-diagnostics.txt +++ /dev/null @@ -1 +0,0 @@ -do not use diff --git a/packages/opencode/src/tool/lsp-hover.ts b/packages/opencode/src/tool/lsp-hover.ts deleted file mode 100644 index 7ef856cc567..00000000000 --- a/packages/opencode/src/tool/lsp-hover.ts +++ /dev/null @@ -1,31 +0,0 @@ -import z from "zod" -import { Tool } from "./tool" -import path from "path" -import { LSP } from "../lsp" -import DESCRIPTION from "./lsp-hover.txt" -import { Instance } from "../project/instance" - -export const LspHoverTool = Tool.define("lsp_hover", { - description: DESCRIPTION, - parameters: z.object({ - file: z.string().describe("The path to the file to get diagnostics."), - line: z.number().describe("The line number to get diagnostics."), - character: z.number().describe("The character number to get diagnostics."), - }), - execute: async (args) => { - const file = path.isAbsolute(args.file) ? args.file : path.join(Instance.directory, args.file) - await LSP.touchFile(file, true) - const result = await LSP.hover({ - ...args, - file, - }) - - return { - title: path.relative(Instance.worktree, file) + ":" + args.line + ":" + args.character, - metadata: { - result, - }, - output: JSON.stringify(result, null, 2), - } - }, -}) diff --git a/packages/opencode/src/tool/lsp-hover.txt b/packages/opencode/src/tool/lsp-hover.txt deleted file mode 100644 index 88a50f6347a..00000000000 --- a/packages/opencode/src/tool/lsp-hover.txt +++ /dev/null @@ -1 +0,0 @@ -do not use diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 68a4fcf54b2..712c1419a32 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -101,11 +101,11 @@ export const WriteTool = Tool.define("write", { const normalizedFilepath = Filesystem.normalizePath(filepath) let projectDiagnosticsCount = 0 for (const [file, issues] of Object.entries(diagnostics)) { - if (issues.length === 0) continue - const sorted = issues.toSorted((a, b) => (a.severity ?? 4) - (b.severity ?? 4)) - const limited = sorted.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const errors = issues.filter((item) => item.severity === 1) + if (errors.length === 0) continue + const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = - issues.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${issues.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" + errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" if (file === normalizedFilepath) { output += `\nThis file has errors, please fix\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n\n` continue diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4afe16b4403..0bc1bb5616c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -598,3 +598,97 @@ test("config tools.ask can enable ask in build", async () => { }, }) }) + +test("compaction config defaults to true when not specified", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + // When not specified, compaction should be undefined (defaults handled in usage) + expect(config.compaction).toBeUndefined() + }, + }) +}) + +test("compaction config can disable auto compaction", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + compaction: { + auto: false, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.compaction?.auto).toBe(false) + expect(config.compaction?.prune).toBeUndefined() + }, + }) +}) + +test("compaction config can disable prune", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + compaction: { + prune: false, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.compaction?.prune).toBe(false) + expect(config.compaction?.auto).toBeUndefined() + }, + }) +}) + +test("compaction config can disable both auto and prune", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + compaction: { + auto: false, + prune: false, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.compaction?.auto).toBe(false) + expect(config.compaction?.prune).toBe(false) + }, + }) +}) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts new file mode 100644 index 00000000000..c20c76a2e7f --- /dev/null +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -0,0 +1,115 @@ +import { test, expect, describe } from "bun:test" +import path from "path" +import { Filesystem } from "../../src/util/filesystem" +import { File } from "../../src/file" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("Filesystem.contains", () => { + test("allows paths within project", () => { + expect(Filesystem.contains("/project", "/project/src")).toBe(true) + expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) + expect(Filesystem.contains("/project", "/project")).toBe(true) + }) + + test("blocks ../ traversal", () => { + expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) + expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + }) + + test("blocks absolute paths outside project", () => { + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) + expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) + }) + + test("handles prefix collision edge cases", () => { + expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) + expect(Filesystem.contains("/project", "/projectfile")).toBe(false) + }) +}) + +/* + * Integration tests for File.read() and File.list() path traversal protection. + * + * These tests verify the HTTP API code path is protected. The HTTP endpoints + * in server.ts (GET /file/content, GET /file) call File.read()/File.list() + * directly - they do NOT go through ReadTool or the agent permission layer. + * + * This is a SEPARATE code path from ReadTool, which has its own checks. + */ +describe("File.read path traversal protection", () => { + test("rejects ../ traversal attempting to read /etc/passwd", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "allowed.txt"), "allowed content") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) + + test("rejects deeply nested traversal", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( + "Access denied: path escapes project directory", + ) + }, + }) + }) + + test("allows valid paths within project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "valid.txt"), "valid content") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("valid.txt") + expect(result.content).toBe("valid content") + }, + }) + }) +}) + +describe("File.list path traversal protection", () => { + test("rejects ../ traversal attempting to list /etc", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) + + test("allows valid subdirectory listing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "file.txt"), "content") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.list("subdir") + expect(Array.isArray(result)).toBe(true) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts new file mode 100644 index 00000000000..de2b14573f4 --- /dev/null +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import path from "path" +import { Session } from "../../src/session" +import { SessionRevert } from "../../src/session/revert" +import { SessionCompaction } from "../../src/session/compaction" +import { MessageV2 } from "../../src/session/message-v2" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Identifier } from "../../src/id/id" +import { tmpdir } from "../fixture/fixture" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("revert + compact workflow", () => { + test("should properly handle compact command after revert", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Create a session + const session = await Session.create({}) + const sessionID = session.id + + // Create a user message + const userMsg1 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + // Add a text part to the user message + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg1.id, + sessionID, + type: "text", + text: "Hello, please help me", + }) + + // Create an assistant response message + const assistantMsg1: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg1.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg1) + + // Add a text part to the assistant message + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg1.id, + sessionID, + type: "text", + text: "Sure, I'll help you!", + }) + + // Create another user message + const userMsg2 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg2.id, + sessionID, + type: "text", + text: "What's the capital of France?", + }) + + // Create another assistant response + const assistantMsg2: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg2.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg2) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg2.id, + sessionID, + type: "text", + text: "The capital of France is Paris.", + }) + + // Verify messages before revert + let messages = await Session.messages({ sessionID }) + expect(messages.length).toBe(4) // 2 user + 2 assistant messages + const messageIds = messages.map((m) => m.info.id) + expect(messageIds).toContain(userMsg1.id) + expect(messageIds).toContain(userMsg2.id) + expect(messageIds).toContain(assistantMsg1.id) + expect(messageIds).toContain(assistantMsg2.id) + + // Revert the last user message (userMsg2) + await SessionRevert.revert({ + sessionID, + messageID: userMsg2.id, + }) + + // Check that revert state is set + let sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeDefined() + const revertMessageID = sessionInfo.revert?.messageID + expect(revertMessageID).toBeDefined() + + // Messages should still be in the list (not removed yet, just marked for revert) + messages = await Session.messages({ sessionID }) + expect(messages.length).toBe(4) + + // Now clean up the revert state (this is what the compact endpoint should do) + await SessionRevert.cleanup(sessionInfo) + + // After cleanup, the reverted messages (those after the revert point) should be removed + messages = await Session.messages({ sessionID }) + const remainingIds = messages.map((m) => m.info.id) + // The revert point is somewhere in the message chain, so we should have fewer messages + expect(messages.length).toBeLessThan(4) + // userMsg2 and assistantMsg2 should be removed (they come after the revert point) + expect(remainingIds).not.toContain(userMsg2.id) + expect(remainingIds).not.toContain(assistantMsg2.id) + + // Revert state should be cleared + sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeUndefined() + + // Clean up + await Session.remove(sessionID) + }, + }) + }) + + test("should properly clean up revert state before creating compaction message", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Create a session + const session = await Session.create({}) + const sessionID = session.id + + // Create initial messages + const userMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID, + type: "text", + text: "Hello", + }) + + const assistantMsg: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg.id, + sessionID, + type: "text", + text: "Hi there!", + }) + + // Revert the user message + await SessionRevert.revert({ + sessionID, + messageID: userMsg.id, + }) + + // Check that revert state is set + let sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeDefined() + + // Simulate what the compact endpoint does: cleanup revert before creating compaction + await SessionRevert.cleanup(sessionInfo) + + // Verify revert state is cleared + sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeUndefined() + + // Verify messages are properly cleaned up + const messages = await Session.messages({ sessionID }) + expect(messages.length).toBe(0) // All messages should be reverted + + // Clean up + await Session.remove(sessionID) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 47a7aee2ae6..eb860d04fcc 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,6 +13,137 @@ const ctx = { metadata: () => {}, } +describe("tool.read external_directory permission", () => { + test("allows reading absolute path inside project directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "hello world") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "test.txt") }, ctx) + expect(result.output).toContain("hello world") + }, + }) + }) + + test("allows reading file in subdirectory inside project directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "test.txt") }, ctx) + expect(result.output).toContain("nested content") + }, + }) + }) + + test("denies reading absolute path outside project directory", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "secret data") + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow( + "not in the current working directory", + ) + }, + }) + }) + + test("denies reading relative path that traverses outside project directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + await expect(read.execute({ filePath: "../../../etc/passwd" }, ctx)).rejects.toThrow( + "not in the current working directory", + ) + }, + }) + }) + + test("allows reading outside project directory when external_directory is allow", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "external.txt"), "external content") + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "allow", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(outerTmp.path, "external.txt") }, ctx) + expect(result.output).toContain("external content") + }, + }) + }) +}) + describe("tool.read env file blocking", () => { test.each([ [".env", true], diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 94930fa446a..4d82f2a5fd8 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f1e0f77a750..ac6f8480269 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 25206de8e84..c7a94f854cf 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1198,6 +1198,10 @@ export type Config = { */ theme?: string keybinds?: KeybindsConfig + /** + * Log level + */ + logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR" /** * TUI specific settings */ diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts index a09e14ab2aa..174131ccfd5 100644 --- a/packages/sdk/js/src/server.ts +++ b/packages/sdk/js/src/server.ts @@ -28,7 +28,10 @@ export async function createOpencodeServer(options?: ServerOptions) { options ?? {}, ) - const proc = spawn(`opencode`, [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`], { + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + + const proc = spawn(`opencode`, args, { signal: options.signal, env: { ...process.env, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 4e9d5bb2143..55cbe56810a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -61,6 +61,7 @@ import type { PartUpdateErrors, PartUpdateResponses, PathGetResponses, + PermissionListResponses, PermissionRespondErrors, PermissionRespondResponses, ProjectBrowseResponses, @@ -1687,6 +1688,25 @@ export class Permission extends HeyApiClient { }, }) } + + /** + * List pending permissions + * + * Get all pending permission requests across all sessions. + */ + public list( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/permission", + ...options, + ...params, + }) + } } export class Askquestion extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4f29267efb5..b3bd564ea82 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1284,6 +1284,29 @@ export type KeybindsConfig = { tips_toggle?: string } +/** + * Log level + */ +export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" + +/** + * Server configuration for opencode serve and web commands + */ +export type ServerConfig = { + /** + * Port to listen on + */ + port?: number + /** + * Hostname to listen on + */ + hostname?: string + /** + * Enable mDNS service discovery + */ + mdns?: boolean +} + export type AgentConfig = { model?: string temperature?: number @@ -1521,6 +1544,7 @@ export type Config = { */ theme?: string keybinds?: KeybindsConfig + logLevel?: LogLevel /** * TUI specific settings */ @@ -1547,6 +1571,7 @@ export type Config = { */ density?: "auto" | "comfortable" | "compact" } + server?: ServerConfig /** * Command configuration, see https://opencode.ai/docs/commands */ @@ -1714,6 +1739,16 @@ export type Config = { */ auth_header_name?: string } + compaction?: { + /** + * Enable automatic compaction when context is full (default: true) + */ + auto?: boolean + /** + * Enable pruning of old tool outputs (default: true) + */ + prune?: boolean + } experimental?: { hook?: { file_edited?: { @@ -3648,6 +3683,24 @@ export type AskquestionCancelResponses = { export type AskquestionCancelResponse = AskquestionCancelResponses[keyof AskquestionCancelResponses] +export type PermissionListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/permission" +} + +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + export type CommandListData = { body?: never path?: never diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts index a09e14ab2aa..174131ccfd5 100644 --- a/packages/sdk/js/src/v2/server.ts +++ b/packages/sdk/js/src/v2/server.ts @@ -28,7 +28,10 @@ export async function createOpencodeServer(options?: ServerOptions) { options ?? {}, ) - const proc = spawn(`opencode`, [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`], { + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + + const proc = spawn(`opencode`, args, { signal: options.signal, env: { ...process.env, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 96ba0720c73..3903566b91e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2879,6 +2879,43 @@ ] } }, + "/permission": { + "get": { + "operationId": "permission.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List pending permissions", + "description": "Get all pending permission requests across all sessions.", + "responses": { + "200": { + "description": "List of pending permissions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + } + ] + } + }, "/command": { "get": { "operationId": "command.list", @@ -7687,6 +7724,32 @@ }, "additionalProperties": false }, + "LogLevel": { + "description": "Log level", + "type": "string", + "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + }, + "ServerConfig": { + "description": "Server configuration for opencode serve and web commands", + "type": "object", + "properties": { + "port": { + "description": "Port to listen on", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "hostname": { + "description": "Hostname to listen on", + "type": "string" + }, + "mdns": { + "description": "Enable mDNS service discovery", + "type": "boolean" + } + }, + "additionalProperties": false + }, "AgentConfig": { "type": "object", "properties": { @@ -8143,6 +8206,9 @@ "keybinds": { "$ref": "#/components/schemas/KeybindsConfig" }, + "logLevel": { + "$ref": "#/components/schemas/LogLevel" + }, "tui": { "description": "TUI specific settings", "type": "object", @@ -8170,6 +8236,9 @@ } } }, + "server": { + "$ref": "#/components/schemas/ServerConfig" + }, "command": { "description": "Command configuration, see https://opencode.ai/docs/commands", "type": "object", @@ -8534,6 +8603,19 @@ } } }, + "compaction": { + "type": "object", + "properties": { + "auto": { + "description": "Enable automatic compaction when context is full (default: true)", + "type": "boolean" + }, + "prune": { + "description": "Enable pruning of old tool outputs (default: true)", + "type": "boolean" + } + } + }, "experimental": { "type": "object", "properties": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 4c2f8eb7356..98cb0d7e7d6 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index 0e7da54bdcb..bb6adb0fb76 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 28320eeb3e9..67720955dcb 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, type JSX } from "solid-js" +import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { Collapsible } from "./collapsible" import { Icon, IconProps } from "./icon" @@ -24,11 +24,18 @@ export interface BasicToolProps { children?: JSX.Element hideDetails?: boolean defaultOpen?: boolean + forceOpen?: boolean } export function BasicTool(props: BasicToolProps) { + const [open, setOpen] = createSignal(props.defaultOpen ?? false) + + createEffect(() => { + if (props.forceOpen) setOpen(true) + }) + return ( - +
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 3f139065a66..c56f477881c 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -18,6 +18,7 @@ const icons = { console: ``, expand: ``, collapse: ``, + code: ``, "code-lines": ``, "circle-ban-sign": ``, "edit-small-2": ``, diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 6daf1a8b513..a8a9e6a31ed 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -361,3 +361,98 @@ overflow: hidden; } } + +[data-component="tool-part-wrapper"] { + width: 100%; + + &[data-permission="true"] { + position: sticky; + top: var(--sticky-header-height, 80px); + bottom: 0px; + z-index: 10; + border-radius: 6px; + border: none; + box-shadow: var(--shadow-xs-border-base); + background-color: var(--surface-raised-base); + overflow: visible; + + &::before { + content: ""; + position: absolute; + inset: -1.5px; + border-radius: 7.5px; + border: 1.5px solid transparent; + background: + linear-gradient(var(--background-base) 0 0) padding-box, + conic-gradient( + from var(--border-angle), + transparent 0deg, + transparent 270deg, + var(--border-warning-strong, var(--border-warning-selected)) 300deg, + var(--border-warning-base) 360deg + ) + border-box; + animation: chase-border 1.5s linear infinite; + pointer-events: none; + z-index: -1; + } + + & > *:first-child { + border-top-left-radius: 6px; + border-top-right-radius: 6px; + overflow: hidden; + } + + & > *:last-child { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + overflow: hidden; + } + + [data-component="collapsible"] { + border: none; + } + + [data-component="card"] { + border: none; + } + } +} + +@property --border-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +@keyframes chase-border { + from { + --border-angle: 0deg; + } + to { + --border-angle: 360deg; + } +} + +[data-component="permission-prompt"] { + display: flex; + flex-direction: column; + padding: 8px 12px; + background-color: var(--surface-raised-strong); + border-radius: 0 0 6px 6px; + + [data-slot="permission-message"] { + display: none; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + } + + [data-slot="permission-actions"] { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + } +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1424041e8c0..0a1518b796e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js" +import { Component, createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { Dynamic } from "solid-js/web" import { AssistantMessage, @@ -16,6 +16,7 @@ import { useDiffComponent } from "../context/diff" import { useCodeComponent } from "../context/code" import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" +import { Button } from "./button" import { Card } from "./card" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -188,11 +189,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { } } -function getToolPartInfo(part: ToolPart): ToolInfo { - const input = part.state.input || {} - return getToolInfo(part.tool, input) -} - export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -334,6 +330,7 @@ export interface ToolProps { status?: string hideDetails?: boolean defaultOpen?: boolean + forceOpen?: boolean } export type ToolComponent = Component @@ -361,11 +358,35 @@ export const ToolRegistry = { } PART_MAPPING["tool"] = function ToolPartDisplay(props) { + const data = useData() const part = props.part as ToolPart + + const permission = createMemo(() => { + const sessionID = props.message.sessionID + const permissions = data.store.permission?.[sessionID] ?? [] + return permissions.find((p) => p.callID === part.callID) + }) + + const [forceOpen, setForceOpen] = createSignal(false) + createEffect(() => { + if (permission()) setForceOpen(true) + }) + + const respond = (response: "once" | "always" | "reject") => { + const perm = permission() + if (!perm || !data.respondToPermission) return + data.respondToPermission({ + sessionID: perm.sessionID, + permissionID: perm.id, + response, + }) + } + const component = createMemo(() => { const render = ToolRegistry.render(part.tool) ?? GenericTool - const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) - const input = part.state.status === "completed" ? part.state.input : {} + // @ts-expect-error + const metadata = part.state?.metadata ?? {} + const input = part.state?.input ?? {} return ( @@ -399,9 +420,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { input={input} tool={part.tool} metadata={metadata} - output={part.state.status === "completed" ? part.state.output : undefined} + // @ts-expect-error + output={part.state.output} status={part.state.status} hideDetails={props.hideDetails} + forceOpen={forceOpen()} defaultOpen={props.defaultOpen} /> @@ -409,7 +432,29 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { ) }) - return {component()} + return ( +
+ {component()} + + {(perm) => ( +
+
{perm().title}
+
+ + + +
+
+ )} +
+
+ ) } PART_MAPPING["text"] = function TextPartDisplay(props) { @@ -564,6 +609,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "task", render(props) { + const data = useData() const summary = () => (props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[] @@ -571,35 +617,141 @@ ToolRegistry.register({ working: () => true, }) + const childSessionId = () => props.metadata.sessionId as string | undefined + + const childPermission = createMemo(() => { + const sessionId = childSessionId() + if (!sessionId) return undefined + const permissions = data.store.permission?.[sessionId] ?? [] + return permissions.toSorted((a, b) => a.id.localeCompare(b.id))[0] + }) + + const childToolPart = createMemo(() => { + const perm = childPermission() + if (!perm) return undefined + const sessionId = childSessionId() + if (!sessionId) return undefined + // Find the tool part that matches the permission's callID + const messages = data.store.message[sessionId] ?? [] + for (const msg of messages) { + const parts = data.store.part[msg.id] ?? [] + for (const part of parts) { + if (part.type === "tool" && (part as ToolPart).callID === perm.callID) { + return { part: part as ToolPart, message: msg } + } + } + } + return undefined + }) + + const respond = (response: "once" | "always" | "reject") => { + const perm = childPermission() + if (!perm || !data.respondToPermission) return + data.respondToPermission({ + sessionID: perm.sessionID, + permissionID: perm.id, + response, + }) + } + + const renderChildToolPart = () => { + const toolData = childToolPart() + if (!toolData) return null + const { part } = toolData + const render = ToolRegistry.render(part.tool) ?? GenericTool + // @ts-expect-error + const metadata = part.state?.metadata ?? {} + const input = part.state?.input ?? {} + return ( + + ) + } + return ( - -
-
- - {(item) => { - const info = getToolInfo(item.tool) - return ( -
- - {info.title} - - {item.state.title} - +
+ + + {(perm) => ( + <> + + } + > + {renderChildToolPart()} + +
+
{perm().title}
+
+ + +
- ) +
+ + )} +
+ + -
-
- + > +
+
+ + {(item) => { + const info = getToolInfo(item.tool) + return ( +
+ + {info.title} + + {item.state.title} + +
+ ) + }} +
+
+
+ + + +
) }, }) @@ -618,7 +770,7 @@ ToolRegistry.register({ >
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 404fcffef3e..86f7b7fe38c 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -385,4 +385,12 @@ [data-slot="session-turn-markdown"] td:first-child { font-family: monospace; } + + [data-slot="session-turn-permission-parts"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a0368b0d492..ce4845a71c0 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -151,6 +151,22 @@ export function SessionTurn( return false }) + const permissionParts = createMemo(() => { + const result: { part: ToolPart; message: AssistantMessage }[] = [] + const permissions = data.store.permission?.[props.sessionID] ?? [] + if (!permissions.length) return result + + for (const m of assistantMessages()) { + const msgParts = data.store.part[m.id] ?? [] + for (const p of msgParts) { + if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) { + result.push({ part: p as ToolPart, message: m }) + } + } + } + return result + }) + const shellModePart = createMemo(() => { const p = parts() if (!p.every((part) => part?.type === "text" && part?.synthetic)) return @@ -469,6 +485,13 @@ export function SessionTurn(
+ 0}> +
+ + {({ part, message }) => } + +
+
{/* Summary */}
diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index c1a29cd04dc..7e90e9f2f32 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -92,6 +92,7 @@ export type ToastVariant = "default" | "success" | "error" | "loading" export interface ToastAction { label: string onClick: "dismiss" | (() => void) + dismissAfter?: boolean } export interface ToastOptions { @@ -128,7 +129,14 @@ export function showToast(options: ToastOptions | string) { {opts.actions!.map((action) => ( diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index f532534188c..3292ba579f0 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, FileDiff, SessionStatus } from "@opencode-ai/sdk/v2" +import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -13,6 +13,9 @@ type Data = { session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] } + permission?: { + [sessionID: string]: Permission[] + } message: { [sessionID: string]: Message[] } @@ -21,9 +24,15 @@ type Data = { } } +export type PermissionRespondFn = (input: { + sessionID: string + permissionID: string + response: "once" | "always" | "reject" +}) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", - init: (props: { data: Data; directory: string }) => { + init: (props: { data: Data; directory: string; onPermissionRespond?: PermissionRespondFn }) => { return { get store() { return props.data @@ -31,6 +40,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, + respondToPermission: props.onPermissionRespond, } }, }) diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index 56be9ee4789..8e1a6aad8e5 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -33,10 +33,6 @@ function init() { }, close() { active()?.onClose?.() - if (!active()?.onClose) { - const promptInput = document.querySelector("[data-component=prompt-input]") as HTMLElement - promptInput?.focus() - } setActive(undefined) }, show(element: DialogElement, owner: Owner, onClose?: () => void) { diff --git a/packages/util/package.json b/packages/util/package.json index c5df6f176bc..f558fdc01c0 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.203", + "version": "1.0.204", "private": true, "type": "module", "exports": { diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 7ecf2bfd9d6..dba43d02fa3 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -36,7 +36,7 @@ export default defineConfig({ expressiveCode: { themes: ["github-light", "github-dark"] }, social: [ { icon: "github", label: "GitHub", href: config.github }, - { icon: "discord", label: "Dscord", href: config.discord }, + { icon: "discord", label: "Discord", href: config.discord }, ], editLink: { baseUrl: `${config.github}/edit/dev/packages/web/`, diff --git a/packages/web/package.json b/packages/web/package.json index 2fb471239b7..866eaab394a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.203", + "version": "1.0.204", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index e4e40ac7a4c..4a826e5b3ff 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -335,10 +335,11 @@ This starts an HTTP server that provides API access to opencode functionality wi #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------- | -| `--port` | `-p` | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Description | +| ------------ | --------------------- | +| `--port` | Port to listen on | +| `--hostname` | Hostname to listen on | +| `--mdns` | Enable mDNS discovery | --- @@ -428,10 +429,11 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------- | -| `--port` | `-p` | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Description | +| ------------ | --------------------- | +| `--port` | Port to listen on | +| `--hostname` | Hostname to listen on | +| `--mdns` | Enable mDNS discovery | --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index ebaff36bb15..d7f8031782c 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -120,6 +120,31 @@ Available options: --- +### Server + +You can configure server settings for the `opencode serve` and `opencode web` commands through the `server` option. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true + } +} +``` + +Available options: + +- `port` - Port to listen on. +- `hostname` - Hostname to listen on. When `mdns` is enabled and no hostname is set, defaults to `0.0.0.0`. +- `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server. + +[Learn more about the server here](/docs/server). + +--- + ### Tools You can manage the tools an LLM can use through the `tools` option. diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index c2c01836bb3..2c0687b8ea5 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -11,26 +11,27 @@ OpenCode automatically formats files after they are written or edited using lang OpenCode comes with several built-in formatters for popular languages and frameworks. Below is a list of the formatters, supported file extensions, and commands or config options it needs. -| Formatter | Extensions | Requirements | -| -------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| gofmt | .go | `gofmt` command available | -| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available | -| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` | -| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://biomejs.dev/) | `biome.json(c)` config file | -| zig | .zig, .zon | `zig` command available | -| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file | -| ktlint | .kt, .kts | `ktlint` command available | -| ruff | .py, .pyi | `ruff` command available with config | -| uv | .py, .pyi | `uv` command available | -| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | -| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available | -| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | -| air | .R | `air` command available | -| dart | .dart | `dart` command available | -| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | -| terraform | .tf, .tfvars | `terraform` command available | -| gleam | .gleam | `gleam` command available | -| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experiental env variable flag](/docs/cli/#experimental) | +| Formatter | Extensions | Requirements | +| -------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| gofmt | .go | `gofmt` command available | +| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available | +| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` | +| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://biomejs.dev/) | `biome.json(c)` config file | +| zig | .zig, .zon | `zig` command available | +| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file | +| ktlint | .kt, .kts | `ktlint` command available | +| ruff | .py, .pyi | `ruff` command available with config | +| uv | .py, .pyi | `uv` command available | +| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | +| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available | +| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | +| air | .R | `air` command available | +| dart | .dart | `dart` command available | +| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | +| terraform | .tf, .tfvars | `terraform` command available | +| gleam | .gleam | `gleam` command available | +| shfmt | .sh, .bash | `shfmt` command available | +| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 25c3ce927a1..63c5d855b9c 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -104,12 +104,14 @@ Or you can set it up manually. OpenCode can be triggered by the following GitHub events: -| Event Type | Triggered By | Details | -| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads the issue/PR context and can create branches, open PRs, or reply with explanations. | -| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context for precise responses. | -| `schedule` | Cron-based schedule | Run OpenCode on a schedule using the `prompt` input. Useful for automated code reviews, reports, or maintenance tasks. OpenCode can create issues or PRs as needed. | -| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews without needing to leave a comment. | +| Event Type | Triggered By | Details | +| ----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads context and can create branches, open PRs, or reply. | +| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context. | +| `issues` | Issue opened or edited | Automatically trigger OpenCode when issues are created or modified. Requires `prompt` input. | +| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews. | +| `schedule` | Cron-based schedule | Run OpenCode on a schedule. Requires `prompt` input. Output goes to logs and PRs (no issue to comment on). | +| `workflow_dispatch` | Manual trigger from GitHub UI | Trigger OpenCode on demand via Actions tab. Requires `prompt` input. Output goes to logs and PRs. | ### Schedule Example @@ -145,9 +147,7 @@ jobs: If you find issues worth addressing, open an issue to track them. ``` -For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from. - -> **Note:** Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs during a scheduled run. +For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from. Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs. --- @@ -188,6 +188,59 @@ For `pull_request` events, if no `prompt` is provided, OpenCode defaults to revi --- +### Issues Triage Example + +Automatically triage new issues. This example filters to accounts older than 30 days to reduce spam: + +```yaml title=".github/workflows/opencode-triage.yml" +name: Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Check account age + id: check + uses: actions/github-script@v7 + with: + script: | + const user = await github.rest.users.getByUsername({ + username: context.payload.issue.user.login + }); + const created = new Date(user.data.created_at); + const days = (Date.now() - created) / (1000 * 60 * 60 * 24); + return days >= 30; + result-encoding: string + + - uses: actions/checkout@v4 + if: steps.check.outputs.result == 'true' + + - uses: sst/opencode/github@latest + if: steps.check.outputs.result == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review this issue. If there's a clear fix or relevant docs: + - Provide documentation links + - Add error handling guidance for code examples + Otherwise, do not comment. +``` + +For `issues` events, the `prompt` input is **required** since there's no comment to extract instructions from. + +--- + ## Custom prompts Override the default prompt to customize OpenCode's behavior for your workflow. diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index 427d8f505ff..c63917f792e 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -18,10 +18,11 @@ opencode serve [--port ] [--hostname ] #### Options -| Flag | Short | Description | Default | -| ------------ | ----- | --------------------- | ----------- | -| `--port` | `-p` | Port to listen on | `4096` | -| `--hostname` | `-h` | Hostname to listen on | `127.0.0.1` | +| Flag | Description | Default | +| ------------ | --------------------- | ----------- | +| `--port` | Port to listen on | `4096` | +| `--hostname` | Hostname to listen on | `127.0.0.1` | +| `--mdns` | Enable mDNS discovery | `false` | --- diff --git a/script/sync-zed.ts b/script/sync-zed.ts index b4a417ad8b9..3ac9ee83a7e 100755 --- a/script/sync-zed.ts +++ b/script/sync-zed.ts @@ -107,7 +107,7 @@ async function main() { console.log(`📬 Creating pull request...`) const prUrl = - await $`gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`.text() + await $`GH_TOKEN=${token} gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`.text() console.log(`✅ Pull request created: ${prUrl}`) console.log(`🎉 Done!`) diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 1b4cf99f985..5d15e76c2eb 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.203", + "version": "1.0.204", "publisher": "sst-dev", "repository": { "type": "git",