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. diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx index e4c9e6e7f3e..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]) @@ -111,7 +121,7 @@ export function Header(props: {
- + +
) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 6e8b401be14..f6a5adeb42a 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -55,7 +55,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( height: 280, }, review: { - opened: true, + opened: false, state: "pane" as "pane" | "tab", width: 450, }, 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__})
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4decec887d9..c45830bea07 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -214,11 +214,11 @@ export default function Page() { sync.session.sync(params.id) }) - // Register mobile review button in header when there are tabs/diffs + // Register mobile review button in header when a session exists + // This allows users to access file browser even when no changes exist yet createEffect(() => { - const hasTabs = showTabs() const filesCount = info()?.summary?.files ?? diffs().length - if (hasTabs) { + if (params.id) { layout.mobileReview.register(filesCount, () => setStore("mobileTabsOpen", true)) } else { layout.mobileReview.unregister() @@ -773,7 +773,7 @@ export default function Page() { ) } - const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0)) + const showTabs = createMemo(() => layout.review.opened()) const tabsValue = createMemo(() => tabs().active() ?? "review") return ( @@ -885,160 +885,160 @@ export default function Page() {
- {/* 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} -
-
- -
- -
-
-
- ) - }} -
-
- ) - }} -
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) || 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..40c44f2d07f 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( @@ -1281,7 +1243,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 +1271,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 +1286,78 @@ 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 + + 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({ + 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 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: messagesAfter[0].info.id, + }) + return messagesAfter[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 +1444,6 @@ export namespace SessionPrompt { model, agent: agentName, parts, - variant: input.variant, })) as MessageV2.WithParts Bus.publish(Command.Event.Executed, { 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") + }) +}) 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 } } } 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 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; 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) }