From 47cc748e0d518f6470d79dd44b6fc2a30d05e36f Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 17:20:07 -0800 Subject: [PATCH 01/11] feat(app): show review pane toggle when session exists - Toggle button now visible whenever a session is active, not just when file changes exist - Review pane defaults to closed instead of open - Mobile review button registers when session exists - Allows users to access file browser before making changes --- packages/app/src/components/header.tsx | 2 +- packages/app/src/context/layout.tsx | 2 +- packages/app/src/pages/session.tsx | 536 +++++++++++++------------ 3 files changed, 271 insertions(+), 269 deletions(-) diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx index e4c9e6e7f3e..fb13334d07e 100644 --- a/packages/app/src/components/header.tsx +++ b/packages/app/src/components/header.tsx @@ -111,7 +111,7 @@ export function Header(props: {
- +
- {/* Tabs pane - visible when there are diffs or file tabs, hidden on mobile */} + {/* Tabs pane - visible when review is opened, hidden on mobile */} @@ -1143,7 +1143,9 @@ export default function Page() { 0}> - {diffs().length > 0 ? "Review Changes" : "Files"} + + {diffs().length > 0 ? "Review Changes" : "Browse Files"} +
{info()?.summary?.files ?? 0} @@ -1162,129 +1164,129 @@ export default function Page() { {/* Mobile tabs content */}
-
- - - -
-
Review
- -
- {info()?.summary?.files ?? 0} +
+ + + +
+
Review
+ +
+ {info()?.summary?.files ?? 0} +
+
-
+ + + + {(tab) => { + const fileName = () => { + if (tab.startsWith("file://")) { + return getFilename(tab.replace("file://", "")) + } + return tab + } + return ( + +
+ + {fileName()} +
+
+ ) + }} +
+
+
+ { + setStore("mobileTabsOpen", false) + dialog.show(() => ) + }} + aria-label="Open file" + /> +
+
+ + +
+ setStore("diffSplit", (x) => !x)} + > + {store.diffSplit ? "Inline" : "Split"} + + } + />
- +
- + {(tab) => { - const fileName = () => { - if (tab.startsWith("file://")) { - return getFilename(tab.replace("file://", "")) - } - return tab - } + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) return ( - -
- - {fileName()} -
-
+ + + {(content) => { + const f = file()! + const isPreviewableImage = + content.encoding === "base64" && + content.mimeType?.startsWith("image/") && + content.mimeType !== "image/svg+xml" + return ( + + +
+ {f.path} +
+
+ +
+ +
+
+
+ ) + }} +
+
) }}
- -
- { - setStore("mobileTabsOpen", false) - dialog.show(() => ) - }} - aria-label="Open file" - /> -
-
- - -
- setStore("diffSplit", (x) => !x)} - > - {store.diffSplit ? "Inline" : "Split"} - - } - /> -
-
-
- - {(tab) => { - const [file] = createResource( - () => tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( - - - {(content) => { - const f = file()! - const isPreviewableImage = - content.encoding === "base64" && - content.mimeType?.startsWith("image/") && - content.mimeType !== "image/svg+xml" - return ( - - -
- {f.path} -
-
- -
- -
-
-
- ) - }} -
-
- ) - }} -
From 894f17221ab79760df82ea7483f7d056272c805d Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 17:20:14 -0800 Subject: [PATCH 02/11] fix(tui): restore variant_cycle keybind in prompt Handle variant_cycle at element level in prompt's onKeyDown handler. Global useKeyboard doesn't receive events properly when textarea is focused, so this keybind must be handled locally. Fixes #222 --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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 958e6ef2ad9..aaa5c0b9cdb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1064,6 +1064,15 @@ export function Prompt(props: PromptProps) { } } if (store.mode === "normal") autocomplete.onKeyDown(e) + // Handle variant cycle before autocomplete visible check + // This must be at element level because global useKeyboard doesn't receive + // events properly when textarea is focused (see issue #222) + if (keybind.match("variant_cycle", e)) { + e.preventDefault() + if (local.model.variant.list().length === 0) return + local.model.variant.cycle() + return + } if (!autocomplete.visible) { if ( (keybind.match("history_previous", e) && input.cursorOffset === 0) || From d4861df26a997c10af7e254ed260bf4f5844c88f Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 17:20:20 -0800 Subject: [PATCH 03/11] feat(script): prioritize changelog over thank yous in Discord posts Discord release notifications now show the changelog (bullet list of changes) first, with contributor thank yous truncated first when hitting the 2000 character limit. Fixes #221 --- script/discord-notify.ts | 119 ++++++++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/script/discord-notify.ts b/script/discord-notify.ts index d8894f60d6b..007841cd2de 100755 --- a/script/discord-notify.ts +++ b/script/discord-notify.ts @@ -6,15 +6,18 @@ * Posts release notes to a Discord forum thread as plain markdown. * Uses Discord REST API directly (no library dependencies). * - * Environment variables: - * DISCORD_TOKEN - Discord user token (self-bot) - * DISCORD_THREAD_ID - Forum thread ID to post to - * RELEASE_VERSION - Version being released (e.g., v1.0.166-11) - * RELEASE_CHANGELOG - Changelog content + * Changelog format from publish.ts: + * - Bullet point changes + * - More changes + * + * **Thank you to N community contributors:** + * - @user: commit message + * + * Discord priority: Changelog > Thank Yous (truncated first when exceeding limit) */ const DISCORD_API = "https://discord.com/api/v10" -const MAX_CONTENT_LENGTH = 2000 // Discord message content limit +const MAX_CONTENT_LENGTH = 2000 async function postToDiscord(threadId: string, token: string, content: string): Promise { console.log("Request body:", JSON.stringify({ content }, null, 2).slice(0, 500) + "...") @@ -36,21 +39,90 @@ async function postToDiscord(threadId: string, token: string, content: string): console.log("Successfully posted release notes to Discord") } +/** + * Extract the changelog portion (everything before the thank you section) + */ +function extractChangelogSection(changelog: string): string { + const thankYouIndex = changelog.indexOf("**Thank you to") + if (thankYouIndex > 0) { + return changelog.slice(0, thankYouIndex).trim() + } + return changelog.trim() +} + +/** + * Extract the contributor thank you section + */ function extractContributorSection(changelog: string): string | null { - // Look for the contributor thank you section const thankYouMatch = changelog.match(/\*\*Thank you to \d+ community contributors?:\*\*[\s\S]*$/) return thankYouMatch ? thankYouMatch[0].trim() : null } -function truncateContent(content: string, maxLength: number): string { - if (content.length <= maxLength) return content +/** + * Truncate text at the last newline before maxLength + */ +function truncateAtNewline(text: string, maxLength: number): string { + if (text.length <= maxLength) return text - // Find a good breaking point (end of a line) - const truncated = content.slice(0, maxLength - 50) + const truncated = text.slice(0, maxLength) const lastNewline = truncated.lastIndexOf("\n") - const cutoff = lastNewline > 0 ? lastNewline : maxLength - 50 - return content.slice(0, cutoff) + "\n\n*...see GitHub for full details.*" + // Use the newline if it's in a reasonable position (>50% of max) + if (lastNewline > maxLength * 0.5) { + return truncated.slice(0, lastNewline) + } + + return truncated +} + +/** + * Build Discord content with smart truncation + * Priority: Header > Changelog > Footer > Thank Yous (truncated first) + */ +function formatDiscordContent( + changelog: string | undefined, + version: string, + releaseUrl: string, + npmUrl: string, +): string { + const header = `**shuvcode ${version}** has been released!\n\n` + const footer = `\n\n[GitHub Release](<${releaseUrl}>) | [npm](<${npmUrl}>)` + const truncationNote = "\n\n*...see GitHub for full details.*" + + // If no changelog, just return header + footer + if (!changelog?.trim()) { + return header.trim() + footer + } + + // Fixed overhead + const fixedOverhead = header.length + footer.length + + // Extract sections + const changelogPart = extractChangelogSection(changelog) + const thankYouPart = extractContributorSection(changelog) + + // Calculate available space for content + const availableForContent = MAX_CONTENT_LENGTH - fixedOverhead + + // Build full content to check length + const fullContent = thankYouPart ? `${changelogPart}\n\n${thankYouPart}` : changelogPart + + // Case 1: Everything fits + if (fullContent.length <= availableForContent) { + return header + fullContent + footer + } + + // Case 2: Changelog fits, but not with thank yous + const changelogWithNote = changelogPart + truncationNote + if (changelogWithNote.length <= availableForContent) { + return header + changelogPart + truncationNote + footer + } + + // Case 3: Changelog itself needs truncation + const maxChangelogLength = availableForContent - truncationNote.length + const truncatedChangelog = truncateAtNewline(changelogPart, maxChangelogLength) + + return header + truncatedChangelog + truncationNote + footer } async function main(): Promise { @@ -78,24 +150,15 @@ async function main(): Promise { const releaseUrl = `https://github.com/Latitudes-Dev/shuvcode/releases/tag/${cleanVersion}` const npmUrl = `https://www.npmjs.com/package/shuvcode/v/${cleanVersion.slice(1)}` - // Build plain markdown content - let content = `**shuvcode ${cleanVersion}** has been released!\n\n` - - // Only include contributor thank yous section, not the full changelog - if (changelog?.trim()) { - const contributorSection = extractContributorSection(changelog) - if (contributorSection) { - content += contributorSection + "\n\n" - } - } - - content += `[GitHub Release](<${releaseUrl}>) | [npm](<${npmUrl}>)` - - // Truncate if too long for Discord - content = truncateContent(content, MAX_CONTENT_LENGTH) + // Build content with changelog prioritized over thank yous + const content = formatDiscordContent(changelog, cleanVersion, releaseUrl, npmUrl) console.log(`Posting release notes for ${cleanVersion} to Discord...`) console.log(`Content length: ${content.length} characters`) + console.log("Content preview:") + console.log("---") + console.log(content.slice(0, 500) + (content.length > 500 ? "..." : "")) + console.log("---") await postToDiscord(threadId, token, content) } From 8beda144abe968fe7d316c449f737e4a87f4d6ea Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 17:20:26 -0800 Subject: [PATCH 04/11] fix(ui): use meslo as default font Set meslo as the default font for both sans and mono font families instead of Inter/IBM Plex Mono from upstream. --- packages/ui/src/styles/theme.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index a8743a2110b..e0170c0c98b 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -1,8 +1,8 @@ :root { - --font-family-sans: "Inter", "Inter Fallback"; - --font-family-sans--font-feature-settings: "ss03" 1; - --font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback"; - --font-family-mono--font-feature-settings: "ss01" 1; + --font-family-sans: "meslo", "Menlo", "Monaco", "Courier New", monospace; + --font-family-sans--font-feature-settings: normal; + --font-family-mono: "meslo", "Menlo", "Monaco", "Courier New", monospace; + --font-family-mono--font-feature-settings: normal; --font-size-small: 13px; --font-size-base: 14px; From e6ad47be552b4991028b8f6751feee2a34dde843 Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 17:20:32 -0800 Subject: [PATCH 05/11] chore: remove resolved context files --- ...15-file-viewer-blank-content-2025-12-29.md | 301 ------------------ CONTEXT/dev-lan-access-issue-2025-12-30.md | 170 ---------- 2 files changed, 471 deletions(-) delete mode 100644 CONTEXT/PLAN-215-file-viewer-blank-content-2025-12-29.md delete mode 100644 CONTEXT/dev-lan-access-issue-2025-12-30.md diff --git a/CONTEXT/PLAN-215-file-viewer-blank-content-2025-12-29.md b/CONTEXT/PLAN-215-file-viewer-blank-content-2025-12-29.md deleted file mode 100644 index cdc68db1c9c..00000000000 --- a/CONTEXT/PLAN-215-file-viewer-blank-content-2025-12-29.md +++ /dev/null @@ -1,301 +0,0 @@ -## Summary - -Address GitHub issue #215: file viewer and Review tab render blank content in the web app. The plan focuses on isolating the root cause (likely CSS containment, worker initialization, or render timing), implementing a fix, and adding regression coverage. - -## Source Context (Issue + Conversation) - -### GitHub Issue #215 (bug report) -- Symptoms: Review tab shows headers only; file tabs are blank; no console errors. -- Expected: diffs and file contents render with syntax highlighting. -- Affected: web app in `packages/app` and UI components in `packages/ui`. -- Suspects: CSS containment (`content-visibility`, `contain-strict`), web worker init, render timing. -- Acceptance criteria: file content visible, diff visible in split/inline, no regression. -- Investigation checklist included in issue. - -### Conversation/Project Context (provided) -- Main branch is `integration`. -- Existing work added a GitHub App (`shuvcode-agent`) and a Cloudflare Worker deployment target (`api.shuv.ai`), but deployment is currently blocked by Durable Objects migration config in `sst.api.config.ts` (unrelated to this issue; noted as background context). - -## Goals - -- Restore rendering of file contents in individual file tabs. -- Restore rendering of diffs in Review tab (split and inline). -- Preserve syntax highlighting and avoid regressions. -- Keep changes compatible with upstream behavior and local builds. - -## Non-Goals - -- No backend or Cloudflare Worker changes for this issue. -- No re-architecture of `@pierre/diffs` usage beyond what is needed to fix the blank rendering. - -## Relevant Internal Files - -| Area | File | Role | -| --- | --- | --- | -| App session UI | `packages/app/src/pages/session.tsx` | Renders Review tab and file tabs using `SessionReview` and `Dynamic` code component | -| Review tab | `packages/ui/src/components/session-review.tsx` | Renders diff UI via `useDiffComponent()` | -| Diff renderer | `packages/ui/src/components/diff.tsx` | Wraps `FileDiff().render()` with worker pool | -| Code renderer | `packages/ui/src/components/code.tsx` | Wraps `File().render()` with worker pool | -| Diff CSS | `packages/ui/src/components/diff.css` | Contains `content-visibility: auto` (likely culprit) | -| Code CSS | `packages/ui/src/components/code.css` | Contains `content-visibility: auto` and `overflow: hidden` | -| Worker pool | `packages/ui/src/pierre/worker.ts` | Initializes `WorkerPoolManager` with Shiki worker | -| Pierre options | `packages/ui/src/pierre/index.ts` | `createDefaultOptions` and `styleVariables` | -| Diff context | `packages/ui/src/context/diff.tsx` | Provider for diff component | -| Code context | `packages/ui/src/context/code.tsx` | Provider for code component | -| App root | `packages/app/src/app.tsx` | Provides diff/code components to app | - -## External References (for worker and render behavior) - -Use these references when validating worker behavior or adjusting how workers are loaded. - -| Topic | Source | Git URL | -| --- | --- | --- | -| Vite worker creation (module) | Example of `new Worker(new URL(...), { type: "module" })` | https://github.com/vitejs/vite/blob/main/docs/guide/features.md | -| Vite worker query usage | Example `?worker` import usage | https://github.com/egoist/haya/blob/main/CHANGELOG.md | -| Vite issue: `?url`/`?worker` in deps | Known limitation in third-party modules | https://github.com/vitejs/vite/issues/10837 | -| Worker creation patterns | Worker with `new URL(..., import.meta.url)` | https://github.com/web-infra-dev/rspack/blob/main/website/docs/en/guide/features/web-workers.mdx | -| content-visibility CSS | MDN documentation | https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility | - -## Technical Summary of Current Flow - -- `SessionReview` requests a diff renderer from `useDiffComponent()` and passes in `before`/`after` file contents. -- `Diff` (`packages/ui/src/components/diff.tsx`) constructs `FileDiff` and calls `render(...)` into a container div inside a `createEffect`. -- `Code` (`packages/ui/src/components/code.tsx`) does the same using `File` and `render(...)`. -- `workerPool` is created client-side only; on SSR it is `undefined`. -- If the `@pierre/diffs` worker fails to load or render, the container is cleared and remains blank. -- **Critical**: Both `diff.css` and `code.css` use `content-visibility: auto` without `contain-intrinsic-size`, which can cause browsers to skip rendering content with zero intrinsic height. -- **Critical**: Parent containers in `session.tsx` use `contain-strict` class which creates containment context. - -## Hypotheses / Failure Modes (Prioritized) - -| Priority | Hypothesis | Evidence to collect | Expected signal | -| --- | --- | --- | --- | -| **1 (HIGH)** | `content-visibility: auto` without `contain-intrinsic-size` causes browser to skip rendering | Inspect computed styles, check if DOM nodes exist but invisible | Nodes in DOM but zero height, removing CSS property fixes issue | -| **2 (HIGH)** | `contain-strict` on parent clips or hides child content | Remove `contain-strict` temporarily | Content renders when removed | -| **3 (MED)** | Worker pool creation throws silently (no try/catch) | Add try/catch, check console | Error logged during pool creation | -| **4 (MED)** | Worker fails to load | Network tab, worker errors, failed chunk load | Missing worker chunk or 404 in DevTools | -| **5 (MED)** | `FileDiff.render()` runs before container has dimensions | Log render timing and container dimensions | render called but container has 0x0 size | -| **6 (LOW)** | `workerPool` is undefined and `@pierre/diffs` doesn't handle it | Log `workerPool` value before render | `workerPool` is undefined on client | -| **7 (LOW)** | Theme "OpenCode" not registered before worker init | Check console for theme errors | Shiki theme error messages | - -## Implementation Plan - -### Milestone 1: CSS Investigation (Highest Priority) - -This is the most likely root cause based on code review. - -- [ ] Inspect the rendered DOM in DevTools: - - Check if `[data-component="diff"]` and `[data-component="code"]` elements exist - - Check their computed `height` and `content-visibility` values - - Check if they have children (rendered content from `@pierre/diffs`) -- [x] Test fix for `content-visibility` by adding `contain-intrinsic-size`: - ```css - [data-component="code"] { - content-visibility: auto; - contain-intrinsic-size: 0 300px; /* Provide minimum intrinsic height */ - overflow: hidden; - } - - [data-component="diff"] { - content-visibility: auto; - contain-intrinsic-size: 0 300px; - } - ``` -- [ ] Test removing `contain-strict` from `Tabs.Content` in `session.tsx:902,937` temporarily -- [ ] If CSS fixes resolve the issue, proceed to Milestone 5 (cleanup). Otherwise, continue to Milestone 2. - -### Milestone 2: Worker Pool Error Handling - -Add defensive error handling to catch silent failures. - -- [x] Add try/catch around worker pool creation in `packages/ui/src/pierre/worker.ts`: - ```typescript - export const workerPool: WorkerPoolManager | undefined = (() => { - if (typeof window === "undefined") { - return undefined - } - try { - return getOrCreateWorkerPoolSingleton({ - poolOptions: { - workerFactory, - poolSize: 2, - }, - highlighterOptions: { - theme: "OpenCode", - }, - }) - } catch (error) { - console.error("[pierre/worker] Failed to create worker pool:", error) - return undefined - } - })() - ``` -- [x] Log the resolved `ShikiWorkerUrl` value to verify it's a valid URL string: - ```typescript - if (import.meta.env.DEV) { - console.debug("[pierre/worker] ShikiWorkerUrl:", ShikiWorkerUrl) - } - ``` -- [ ] Verify worker script loads in Network tab (filter by "worker" type) -- [ ] Check browser console for any worker-related errors - -### Milestone 3: Render Lifecycle Validation - -Ensure render is called when container is ready and has dimensions. - -- [ ] Add debug logging to `diff.tsx` and `code.tsx` (use debug flag): - ```typescript - createEffect(() => { - if (import.meta.env.DEV && new URLSearchParams(location.search).has('debug')) { - console.debug('[Diff] Rendering', { - containerExists: !!container, - containerDimensions: container ? { w: container.offsetWidth, h: container.offsetHeight } : null, - workerPoolExists: !!workerPool, - }) - } - container.innerHTML = "" - fileDiff().render({ ... }) - }) - ``` -- [ ] If container has zero dimensions, defer render using `requestAnimationFrame`: - ```typescript - createEffect(() => { - const doRender = () => { - if (container.offsetWidth === 0 || container.offsetHeight === 0) { - requestAnimationFrame(doRender) - return - } - container.innerHTML = "" - fileDiff().render({ ... }) - } - doRender() - }) - ``` -- [ ] Verify `@pierre/diffs` behavior when `workerPool` is `undefined`: - - Check if it falls back to synchronous rendering - - Check if it throws or fails silently - - Add explicit handling if needed - -### Milestone 4: Accordion & Tab Visibility - -Ensure content renders correctly when accordion expands or tabs switch. - -- [ ] Verify `SessionReview` accordion items trigger re-render on expand -- [ ] Check that tab content re-renders when tab becomes active -- [ ] If needed, add explicit reactive trigger on visibility change - -### Milestone 5: Finalize Fix & Regression Coverage - -- [ ] Remove all debug logging (or gate behind `import.meta.env.DEV`) -- [ ] Verify fix works in: - - [ ] Chrome/Chromium - - [ ] Safari (known to have slower worker boot) - - [ ] Firefox - - [ ] Mobile Safari - - [ ] Mobile Chrome -- [ ] Verify both dev (`bun dev`) and production (`bun build`) builds -- [x] Add console warning for silent render failures: - ```typescript - createEffect(() => { - container.innerHTML = "" - fileDiff().render({ ... }) - // Check if render succeeded after microtask - queueMicrotask(() => { - if (container.children.length === 0) { - console.warn('[Diff] Render may have failed - container is empty') - } - }) - }) - ``` - -### Milestone 6: Testing Strategy - -**Note**: UI tests should NOT go in `packages/opencode/test/` - that directory contains backend/CLI tests only. - -- [ ] Create manual testing checklist: - 1. Open web app at `http://localhost:3000` - 2. Create or open a session with file changes - 3. Navigate to Review tab - verify diffs render - 4. Toggle between Split and Inline modes - 5. Click on individual file tabs - verify code renders with syntax highlighting - 6. Collapse and expand accordion items in Review tab - 7. Test on mobile viewport (use DevTools device mode) -- [ ] Future: Consider adding E2E tests in `packages/app/test/e2e/` using Playwright -- [ ] Document the fix in a code comment explaining the CSS containment issue - -## Debugging Commands - -```bash -# Start dev server -cd packages/app && bun dev - -# Open with debug flag -open "http://localhost:3000//session/?debug=diffs" - -# Check worker chunk exists in build -cd packages/app && bun build && ls -la dist/assets | grep worker - -# Check @pierre/diffs version -cat package.json | grep '@pierre/diffs' -``` - -## Validation Criteria - -### Functional Checks -- [ ] Review tab renders diff content for at least one file -- [ ] File tabs display the file contents with syntax highlighting -- [ ] Split and inline diff modes both render correctly -- [ ] Accordion expand/collapse works correctly -- [ ] Mobile overlay (`Portal`) renders content correctly - -### Technical Checks -- [ ] Worker script loads successfully in dev and production builds -- [ ] No console errors during normal operation -- [ ] `content-visibility` doesn't cause invisible content -- [ ] Worker pool creation failure is logged if it occurs - -## Implementation Order (Dependencies) - -1. **CSS containment fix** (Milestone 1) - most likely cause, try first -2. **Worker error handling** (Milestone 2) - defensive, do regardless -3. **Render lifecycle fixes** (Milestone 3) - if CSS fix isn't sufficient -4. **Accordion/tab visibility** (Milestone 4) - if content appears then disappears -5. **Cleanup and testing** (Milestones 5-6) - always do last - -## Risk Assessment - -| Change | Risk Level | Mitigation | -| --- | --- | --- | -| Adding `contain-intrinsic-size` to CSS | Low | Standard CSS property, easy to revert | -| Adding try/catch to worker pool | Low | Defensive code, no behavior change on success | -| Deferring render with `requestAnimationFrame` | Medium | Could cause flash of unstyled content; test thoroughly | -| Removing `contain-strict` | Medium | May affect layout in other areas; prefer targeted fix | - -## Rollback Strategy - -If the fix introduces regressions: -1. Revert the commit with `git revert ` -2. All changes are in `packages/ui` and `packages/app` - no backend impact -3. No database or API changes - clean rollback - -## Browser Testing Matrix - -| Browser | Platform | Priority | Notes | -| --- | --- | --- | --- | -| Chrome | Desktop | P0 | Primary development browser | -| Safari | Desktop | P0 | Known slower worker boot (poolSize=2 accommodates this) | -| Firefox | Desktop | P1 | Secondary browser | -| Chrome | Mobile | P1 | Mobile view uses Portal for tabs | -| Safari | Mobile | P1 | iOS primary browser | - -## Open Questions (Resolved) - -- ~~Should the regression test live in `packages/opencode/test/`?~~ **No** - that directory is for backend/CLI tests. UI tests should go in `packages/app/test/` or remain as manual testing checklist until E2E infrastructure is added. -- ~~Is this bug reproducible in all browsers?~~ **Unknown** - testing matrix added above to validate. - -## Notes - -- No changes to Cloudflare deployment or `sst.api.config.ts` are required for this issue. -- Keep changes minimal to ease upstream alignment with `sst/opencode`. -- The `@pierre/diffs` package version is `1.0.2` (from root `package.json`). -- Theme "OpenCode" is registered in `MarkedProvider` - ensure this happens before worker pool access. diff --git a/CONTEXT/dev-lan-access-issue-2025-12-30.md b/CONTEXT/dev-lan-access-issue-2025-12-30.md deleted file mode 100644 index d097ce7d0dc..00000000000 --- a/CONTEXT/dev-lan-access-issue-2025-12-30.md +++ /dev/null @@ -1,170 +0,0 @@ -# Dev Environment LAN Access Issue - -**Date:** 2025-12-30 -**Status:** Unresolved -**Affects:** Development environment only (not production) - -## Problem Summary - -When accessing the Vite dev server from a LAN IP address (e.g., `http://10.0.2.100:3000/`), the web app fails to connect to the backend opencode server, even though both servers are bound to `0.0.0.0`. - -## Environment - -- **Vite dev server:** `bun run dev` → listening on `0.0.0.0:3000` -- **OpenCode server:** `bun run dev serve --port 4096 --hostname 0.0.0.0 --print-logs` -- **Access method:** Browser on same machine or LAN device via IP address - -## Error Messages - -### Original (before attempted fix): -``` -Error: Could not connect to server. Is there a server running at `http://localhost:4096`? - at bootstrap (http://10.0.2.100:3000/src/context/global-sync.tsx:317:31) -``` - -### After attempted fix (using `location.hostname`): -``` -Error: Could not connect to server. Is there a server running at `http://10.0.2.100:4096`? - at bootstrap (http://10.0.2.100:3000/src/context/global-sync.tsx:317:31) -``` - -## Analysis - -### What's happening: - -1. When accessing via `http://10.0.2.100:3000/`, the browser correctly loads the Vite dev server -2. The app tries to connect to the OpenCode API server -3. With original code: tries `http://localhost:4096` (wrong host from browser's perspective on LAN) -4. With fix attempt: tries `http://10.0.2.100:4096` (correct host, but still fails) - -### Why the fix didn't work: - -The issue is **NOT** the URL resolution logic. The URL `http://10.0.2.100:4096` is correct. The problem is one of: - -1. **CORS (Cross-Origin Resource Sharing)** - - Browser origin: `http://10.0.2.100:3000` - - API request to: `http://10.0.2.100:4096` - - These are different origins (different ports) - - The OpenCode server may not be sending proper CORS headers for this origin - -2. **Vite Proxy Not Being Used** - - In dev mode, Vite is configured with a proxy to forward `/api/*` requests to the backend - - But if the app is constructing absolute URLs like `http://10.0.2.100:4096`, it bypasses the Vite proxy entirely - - The proxy only works for relative URLs or same-origin requests - -3. **Network/Firewall** - - Less likely since both servers are on same machine, but port 4096 could be blocked for non-localhost - -## Current Server URL Resolution Logic - -```typescript -const defaultServerUrl = iife(() => { - // 1. Query parameter (highest priority) - const param = new URLSearchParams(document.location.search).get("url") - if (param) return param - - // 2. Known production hosts -> localhost - if (location.hostname.includes("opencode.ai") || location.hostname.includes("shuv.ai")) - return "http://localhost:4096" - - // 3. Desktop app (Tauri) with injected port - if (window.__SHUVCODE__?.port) return `http://127.0.0.1:${window.__SHUVCODE__.port}` - if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}` - - // 4. Dev mode -> explicit host:port from env - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - - // 5. Default -> same origin (production web command) - return window.location.origin -}) -``` - -## Potential Solutions - -### Option 1: Use Vite Proxy in Dev Mode (Recommended) - -Instead of returning an absolute URL in dev mode, return a relative URL so requests go through Vite's proxy: - -```typescript -// 4. Dev mode -> use relative URL to go through Vite proxy -if (import.meta.env.DEV) return "/" -``` - -This requires the Vite proxy to be properly configured in `vite.config.ts` to forward API requests to `localhost:4096`. - -**Pros:** -- Works regardless of how you access the dev server (localhost, IP, hostname) -- No CORS issues since requests are same-origin -- Already partially configured in vite.config.ts - -**Cons:** -- Need to ensure all API routes are proxied -- Slightly different behavior than production - -### Option 2: Configure CORS on OpenCode Server - -Add CORS headers to the OpenCode server to allow requests from any origin in dev mode: - -```typescript -// In packages/opencode/src/server/server.ts -if (isDev) { - app.use('*', (c, next) => { - c.header('Access-Control-Allow-Origin', '*') - c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - c.header('Access-Control-Allow-Headers', '*') - return next() - }) -} -``` - -**Pros:** -- Allows direct access to API from any origin -- Useful for debugging API directly - -**Cons:** -- Security consideration (dev only) -- Need to modify server code - -### Option 3: Environment Variable Override - -Set `VITE_OPENCODE_SERVER_HOST=0.0.0.0` or the specific IP when starting dev server. - -**Pros:** -- Simple, no code changes -- Explicit control - -**Cons:** -- Manual configuration required -- Still has CORS issues - -### Option 4: Use Same-Origin Detection (Our Previous Implementation) - -Our previous (more complex) implementation had logic to detect when to use same-origin requests: - -```typescript -const isLoopback = ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) -const isWebCommand = !import.meta.env.DEV -const useSameOrigin = isSecure || isKnownHost || (isLoopback && !import.meta.env.DEV) || isWebCommand - -if (useSameOrigin) return "/" -``` - -This was more complex but handled the case of non-loopback access in dev mode. - -## Recommended Next Steps - -1. **Verify the Vite proxy configuration** in `packages/app/vite.config.ts` -2. **Test Option 1** - Return `/` in dev mode and ensure Vite proxy forwards correctly -3. **If proxy approach doesn't work**, investigate CORS headers on the OpenCode server - -## Files Involved - -- `packages/app/src/app.tsx` - Server URL resolution -- `packages/app/vite.config.ts` - Vite proxy configuration -- `packages/app/src/context/global-sync.tsx` - Where the connection error originates -- `packages/opencode/src/server/server.ts` - Backend server (if CORS fix needed) - -## Workaround - -For now, access the dev server via `http://localhost:3000/` instead of IP address. From 3d9de36d7082ebea213f826ad9262f18db214c48 Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 17:46:40 -0800 Subject: [PATCH 06/11] feat(app): add theme and font pickers to header and mobile sidebar --- packages/app/src/components/header.tsx | 16 +++++++++++++++- packages/app/src/pages/layout.tsx | 2 ++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx index fb13334d07e..9a620c90342 100644 --- a/packages/app/src/components/header.tsx +++ b/packages/app/src/components/header.tsx @@ -16,6 +16,8 @@ import { A, useParams } from "@solidjs/router" import { createMemo, createResource, Show } from "solid-js" import { IconButton } from "@opencode-ai/ui/icon-button" import { iife } from "@opencode-ai/util/iife" +import { ThemePicker } from "@/components/theme-picker" +import { FontPicker } from "@/components/font-picker" export function Header(props: { navigateToProject: (directory: string) => void @@ -53,7 +55,15 @@ export function Header(props: {
- 0 && params.dir}> + 0 && params.dir} + fallback={ + + } + > {(directory) => { const currentDirectory = createMemo(() => base64Decode(directory())) const store = createMemo(() => globalSync.child(currentDirectory())[0]) @@ -206,6 +216,10 @@ export function Header(props: { })} +
) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 24bf6e4d27f..67e6b132252 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1039,6 +1039,8 @@ export default function Layout(props: ParentProps) { + +
v{__APP_VERSION__} ({__COMMIT_HASH__})
From 57ac84410e34fc729575c06fef6b4dcc43c93b24 Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 17:57:54 -0800 Subject: [PATCH 07/11] feat(opencode): fix plugin commands with type field and arguments Cherry-picked from dev-pr branch (ba8144c22) with conflict resolution: - Added 'type' field to Command.Info schema (template|plugin) - Added session-only guard for plugin commands - Pass 'arguments' to plugin command execute function - Preserved fork-specific bundleLocalPlugin and serverUrl features --- packages/opencode/src/command/index.ts | 12 +- packages/opencode/src/plugin/index.ts | 1 + packages/opencode/src/session/prompt.ts | 187 ++++++++++-------------- packages/plugin/src/index.ts | 10 +- 4 files changed, 96 insertions(+), 114 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 24e341f18dc..a037408391d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -28,6 +28,7 @@ export namespace Command { agent: z.string().optional(), model: z.string().optional(), template: z.string(), + type: z.enum(["template", "plugin"]).default("template"), subtask: z.boolean().optional(), sessionOnly: z.boolean().optional(), aliases: z.array(z.string()).optional(), @@ -48,11 +49,13 @@ export namespace Command { const result: Record = { [Default.INIT]: { name: Default.INIT, + type: "template", description: "create/update AGENTS.md", template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), }, [Default.REVIEW]: { name: Default.REVIEW, + type: "template", description: "review changes [commit|branch|pr], defaults to uncommitted", template: PROMPT_REVIEW.replace("${path}", Instance.worktree), subtask: true, @@ -62,6 +65,7 @@ export namespace Command { for (const [name, command] of Object.entries(cfg.command ?? {})) { result[name] = { name, + type: "template", agent: command.agent, model: command.model, description: command.description, @@ -70,16 +74,18 @@ export namespace Command { } } + // Plugin commands const plugins = await Plugin.list() for (const plugin of plugins) { const commands = plugin["plugin.command"] if (!commands) continue for (const [name, cmd] of Object.entries(commands)) { - if (result[name]) continue + if (result[name]) continue // Don't override existing commands result[name] = { name, + type: "plugin", description: cmd.description, - template: "", + template: "", // Plugin commands don't use templates sessionOnly: cmd.sessionOnly, aliases: cmd.aliases, } @@ -92,7 +98,7 @@ export namespace Command { export async function get(name: string) { const commands = await state() if (commands[name]) return commands[name] - // Check aliases + // Resolve aliases for (const cmd of Object.values(commands)) { if (cmd.aliases?.includes(name)) return cmd } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 66540001592..8089ea407f0 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -120,6 +120,7 @@ export namespace Plugin { // pathToFileURL ensures proper URL encoding regardless of import.meta.url context const mod = await import(pluginUrl) for (const [_name, fn] of Object.entries(mod)) { + if (typeof fn !== "function") continue const init = await fn(input) hooks.push(init) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a4e6eee3cff..87b78a58fdd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -90,7 +90,6 @@ export namespace SessionPrompt { noReply: z.boolean().optional(), tools: z.record(z.string(), z.boolean()).optional(), system: z.string().optional(), - variant: z.string().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -164,14 +163,8 @@ export namespace SessionPrompt { await Promise.all( files.map(async (match) => { const name = match[1] - const startLine = match[2] // Captured from #L - const endLine = match[3] // Captured from #L- - - // Use full match key for deduplication (includes line range) - const matchKey = match[0] - if (seen.has(matchKey)) return - seen.add(matchKey) - + if (seen.has(name)) return + seen.add(name) const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -188,23 +181,6 @@ export namespace SessionPrompt { return } - // Build URL with optional line range query params - let url = `file://${filepath}` - if (startLine) { - const params = new URLSearchParams() - params.set("start", startLine) - if (endLine) { - params.set("end", endLine) - } - url += `?${params.toString()}` - } - - // Build filename with line range for display - let filename = name - if (startLine) { - filename += endLine ? `#L${startLine}-${endLine}` : `#L${startLine}` - } - if (stats.isDirectory()) { parts.push({ type: "file", @@ -217,8 +193,8 @@ export namespace SessionPrompt { parts.push({ type: "file", - url, - filename, + url: `file://${filepath}`, + filename: name, mime: "text/plain", }) }), @@ -634,34 +610,21 @@ export namespace SessionPrompt { extra: { model: input.model }, agent: input.agent.name, metadata: async (val) => { - const findPart = async (retries: number): Promise => { - const match = input.processor.partFromToolCall(options.toolCallId) - if (match?.state.status === "running") return match as MessageV2.ToolPart - if (retries >= 20) return undefined - await new Promise((resolve) => setTimeout(resolve, 50)) - return findPart(retries + 1) - } - - const match = await findPart(0) - if (!match) { - log.warn("metadata: part not found or not running after waiting", { - toolCallId: options.toolCallId, + const match = input.processor.partFromToolCall(options.toolCallId) + if (match && match.state.status === "running") { + await Session.updatePart({ + ...match, + state: { + title: val.title, + metadata: val.metadata, + status: "running", + input: args, + time: { + start: Date.now(), + }, + }, }) - return } - - await Session.updatePart({ - ...match, - state: { - title: val.title, - metadata: val.metadata, - status: "running", - input: args, - time: { - start: Date.now(), - }, - }, - }) }, }) await Plugin.trigger( @@ -764,7 +727,6 @@ export namespace SessionPrompt { agent: agent.name, model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), system: input.system, - variant: input.variant, } const parts = await Promise.all( @@ -1116,10 +1078,6 @@ export namespace SessionPrompt { } await Session.updatePart(userPart) - // Use session.directory as the authoritative source for cwd - // This ensures shell commands work correctly even if Instance.directory - // hasn't been properly initialized yet (e.g., first message in a new project) - const cwd = session.directory const msg: MessageV2.Assistant = { id: Identifier.ascending("message"), sessionID: input.sessionID, @@ -1128,7 +1086,7 @@ export namespace SessionPrompt { agent: input.agent, cost: 0, path: { - cwd, + cwd: Instance.directory, root: Instance.worktree, }, time: { @@ -1219,7 +1177,7 @@ export namespace SessionPrompt { const args = matchingInvocation?.args const proc = spawn(shell, args, { - cwd, + cwd: Instance.directory, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: { @@ -1281,7 +1239,6 @@ export namespace SessionPrompt { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } msg.time.completed = Date.now() - msg.finish = "stop" await Session.updateMessage(msg) if (part.state.status === "running") { part.state = { @@ -1310,7 +1267,6 @@ export namespace SessionPrompt { model: z.string().optional(), arguments: z.string(), command: z.string(), - variant: z.string().optional(), }) export type CommandInput = z.infer const bashRegex = /!`([^`]+)`/g @@ -1326,59 +1282,75 @@ export namespace SessionPrompt { export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) - const agentName = command?.agent ?? input.agent ?? (await Agent.defaultAgent()) - - const plugins = await Plugin.list() - for (const plugin of plugins) { - const pluginCommands = plugin["plugin.command"] - const pluginCommand = pluginCommands?.[input.command] - if (!pluginCommand) continue + if (!command) { + log.warn("command not found", { command: input.command }) + return + } - const client = await Plugin.client() + if (command.sessionOnly) { try { - await pluginCommand.execute({ sessionID: input.sessionID, client }) + await Session.get(input.sessionID) } catch (error) { - log.error("plugin command failed", { - command: input.command, - error: error instanceof Error ? error.message : String(error), + const message = `/${command.name} requires an existing session` + log.warn("session-only command blocked", { + command: command.name, + sessionID: input.sessionID, + error, }) - return await SessionPrompt.prompt({ + Bus.publish(Session.Event.Error, { sessionID: input.sessionID, - agent: agentName, - parts: [ - { - type: "text", - text: `Plugin command "/${input.command}" failed: ${error instanceof Error ? error.message : String(error)}`, - }, - ], + error: new NamedError.Unknown({ + message, + }).toObject(), }) + throw new Error(message) } - const last = await Session.messages({ sessionID: input.sessionID, limit: 1 }) - const message = last.at(0) - if (message) return message - return await SessionPrompt.prompt({ - sessionID: input.sessionID, - agent: agentName, - parts: [ - { - type: "text", - text: "", - }, - ], - }) } - if (!command) - return await SessionPrompt.prompt({ - sessionID: input.sessionID, - agent: agentName, - parts: [ - { - type: "text", - text: "", - }, - ], - }) + // Plugin commands execute directly via hook + if (command.type === "plugin") { + const plugins = await Plugin.list() + for (const plugin of plugins) { + const pluginCommands = plugin["plugin.command"] + const pluginCommand = pluginCommands?.[command.name] + if (!pluginCommand) continue + + try { + const client = await Plugin.client() + await pluginCommand.execute({ + sessionID: input.sessionID, + arguments: input.arguments, + client, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.error("plugin command failed", { command: command.name, error: message }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ + message: `/${command.name} failed: ${message}`, + }).toObject(), + }) + throw error + } + + // Emit event if plugin created a message + const last = await Session.messages({ sessionID: input.sessionID, limit: 1 }) + if (last.length > 0) { + Bus.publish(Command.Event.Executed, { + name: command.name, + sessionID: input.sessionID, + arguments: input.arguments, + messageID: last[0].info.id, + }) + return last[0] + } + return + } + return + } + + const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) @@ -1465,7 +1437,6 @@ export namespace SessionPrompt { model, agent: agentName, parts, - variant: input.variant, })) as MessageV2.WithParts Bus.publish(Command.Event.Executed, { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 23370b8acf0..26368f14611 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -208,14 +208,18 @@ export interface Hooks { output: { text: string }, ) => Promise /** - * Register custom plugin commands (accessible via /command in TUI) + * Register custom slash commands (accessible via /command in TUI/web) */ "plugin.command"?: { - [key: string]: { + [name: string]: { description: string aliases?: string[] sessionOnly?: boolean - execute(input: { sessionID?: string; client: ReturnType }): Promise + execute(input: { + sessionID: string + arguments: string + client: ReturnType + }): Promise } } } From 8d50f1c64f8b93997929ed8bafbd6bb1ae68bb6e Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:24:16 -0800 Subject: [PATCH 08/11] test(opencode): cover plugin commands and session-only guard --- .../test/command/plugin-commands.test.ts | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 packages/opencode/test/command/plugin-commands.test.ts diff --git a/packages/opencode/test/command/plugin-commands.test.ts b/packages/opencode/test/command/plugin-commands.test.ts new file mode 100644 index 00000000000..90913a59eae --- /dev/null +++ b/packages/opencode/test/command/plugin-commands.test.ts @@ -0,0 +1,157 @@ +import { test, expect, mock } from "bun:test" +import { tmpdir } from "../fixture/fixture" + +const pluginModulePath = new URL("../../src/plugin/index.ts", import.meta.url).pathname + +let pluginHook: Record = {} +const executeCalls: Array> = [] +const fakeClient = { + tui: { + publish: async () => {}, + }, +} + +mock.module(pluginModulePath, () => ({ + Plugin: { + list: async () => [pluginHook], + client: async () => fakeClient, + trigger: async (_name: string, _input: unknown, output: unknown) => output, + }, +})) + +const { Instance } = await import("../../src/project/instance") +const { Session } = await import("../../src/session") +const { SessionPrompt } = await import("../../src/session/prompt") +const { Command } = await import("../../src/command") +const { Bus } = await import("../../src/bus") +const { Identifier } = await import("../../src/id/id") + +async function withInstance(fn: () => Promise) { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await fn() + await Instance.dispose() + }, + }) +} + +test("Command.get resolves plugin aliases", async () => { + pluginHook = { + "plugin.command": { + hello: { + description: "hello", + aliases: ["hi"], + sessionOnly: false, + execute: async () => {}, + }, + }, + } + + await withInstance(async () => { + const cmd = await Command.get("hi") + expect(cmd?.name).toBe("hello") + expect(cmd?.type).toBe("plugin") + }) +}) + +test("SessionPrompt.command executes plugin command", async () => { + executeCalls.length = 0 + pluginHook = { + "plugin.command": { + hello: { + description: "hello", + sessionOnly: false, + execute: async (input: { sessionID: string; arguments: string }) => { + executeCalls.push(input) + }, + }, + }, + } + + await withInstance(async () => { + const session = await Session.create({}) + await SessionPrompt.command({ + sessionID: session.id, + command: "hello", + arguments: "world", + }) + expect(executeCalls.length).toBe(1) + expect(executeCalls[0].arguments).toBe("world") + }) +}) + +test("SessionPrompt.command publishes error on plugin failure", async () => { + pluginHook = { + "plugin.command": { + boom: { + description: "boom", + sessionOnly: false, + execute: async () => { + throw new Error("boom") + }, + }, + }, + } + + await withInstance(async () => { + const session = await Session.create({}) + const errors: Array<{ type: string; properties: any }> = [] + const unsubscribe = Bus.subscribe(Session.Event.Error, (event) => { + errors.push(event) + }) + + await expect( + SessionPrompt.command({ + sessionID: session.id, + command: "boom", + arguments: "", + }), + ).rejects.toThrow("boom") + + await new Promise((resolve) => setTimeout(resolve, 0)) + unsubscribe() + + expect(errors.length).toBe(1) + expect(JSON.stringify(errors[0].properties.error)).toContain("/boom failed") + }) +}) + +test("SessionPrompt.command blocks session-only commands for missing sessions", async () => { + executeCalls.length = 0 + pluginHook = { + "plugin.command": { + hello: { + description: "hello", + sessionOnly: true, + execute: async (input: { sessionID: string; arguments: string }) => { + executeCalls.push(input) + }, + }, + }, + } + + await withInstance(async () => { + const missingSessionID = Identifier.ascending("session") + const errors: Array<{ type: string; properties: any }> = [] + const unsubscribe = Bus.subscribe(Session.Event.Error, (event) => { + errors.push(event) + }) + + await expect( + SessionPrompt.command({ + sessionID: missingSessionID, + command: "hello", + arguments: "", + }), + ).rejects.toThrow("requires an existing session") + + await new Promise((resolve) => setTimeout(resolve, 0)) + unsubscribe() + + expect(executeCalls.length).toBe(0) + expect(errors.length).toBe(1) + expect(JSON.stringify(errors[0].properties.error)).toContain("/hello requires an existing session") + }) +}) From 513263e61718d614f6e4d3d40209cde1b0e14d87 Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:24:21 -0800 Subject: [PATCH 09/11] chore(sdk): keep command types aligned --- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index eeae051ed0c..8a854c0a51f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1871,6 +1871,7 @@ export type Command = { agent?: string model?: string template: string + type: "template" | "plugin" subtask?: boolean sessionOnly?: boolean aliases?: Array From bbc5e4a3e52d2786d4b1a1ab8c92d2bf8bef7f1a Mon Sep 17 00:00:00 2001 From: shuv Date: Sat, 27 Dec 2025 09:39:27 -0800 Subject: [PATCH 10/11] fix: only emit Command.Event.Executed when plugin creates new message --- packages/opencode/src/session/prompt.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 87b78a58fdd..c5d3654c051 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1315,6 +1315,9 @@ export namespace SessionPrompt { const pluginCommand = pluginCommands?.[command.name] if (!pluginCommand) continue + const messagesBefore = await Session.messages({ sessionID: input.sessionID, limit: 1 }) + const lastMessageIDBefore = messagesBefore[0]?.info.id + try { const client = await Plugin.client() await pluginCommand.execute({ @@ -1334,16 +1337,16 @@ export namespace SessionPrompt { throw error } - // Emit event if plugin created a message - const last = await Session.messages({ sessionID: input.sessionID, limit: 1 }) - if (last.length > 0) { + // Emit event if plugin created a new message + const messagesAfter = await Session.messages({ sessionID: input.sessionID, limit: 1 }) + if (messagesAfter.length > 0 && messagesAfter[0].info.id !== lastMessageIDBefore) { Bus.publish(Command.Event.Executed, { name: command.name, sessionID: input.sessionID, arguments: input.arguments, - messageID: last[0].info.id, + messageID: messagesAfter[0].info.id, }) - return last[0] + return messagesAfter[0] } return } From 689f447a82ce3e603ef2965285abab0c54d1aacc Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 30 Dec 2025 18:12:02 -0800 Subject: [PATCH 11/11] fix: restore session.directory for shell command cwd Applied Greptile review suggestion - restores fix from commit 0953f75b that ensures shell commands use session.directory for correct working directory, especially on first message in new projects. --- packages/opencode/src/session/prompt.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c5d3654c051..40c44f2d07f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1078,6 +1078,10 @@ export namespace SessionPrompt { } await Session.updatePart(userPart) + // Use session.directory as the authoritative source for cwd + // This ensures shell commands work correctly even if Instance.directory + // hasn't been properly initialized yet (e.g., first message in a new project) + const cwd = session.directory const msg: MessageV2.Assistant = { id: Identifier.ascending("message"), sessionID: input.sessionID, @@ -1086,7 +1090,7 @@ export namespace SessionPrompt { agent: input.agent, cost: 0, path: { - cwd: Instance.directory, + cwd, root: Instance.worktree, }, time: { @@ -1177,7 +1181,7 @@ export namespace SessionPrompt { const args = matchingInvocation?.args const proc = spawn(shell, args, { - cwd: Instance.directory, + cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: {