From d0637d81bc9b1fe81d7ae3cecaa2a452195e4354 Mon Sep 17 00:00:00 2001 From: adityazxxx Date: Tue, 22 Sep 2026 11:56:42 +0700 Subject: [PATCH 1/5] feat: add OpenCode extension --- extensions/opencode/README.md | 75 ++ extensions/opencode/assets/icon.png | Bin 0 -> 1961 bytes extensions/opencode/assets/icon@dark.png | Bin 0 -> 1961 bytes extensions/opencode/package-lock.json | 850 ++++++++++++++++++ extensions/opencode/package.json | 121 +++ extensions/opencode/src/ask.tsx | 402 +++++++++ .../opencode/src/components/actions.tsx | 150 ++++ .../opencode/src/components/command-gate.tsx | 31 + .../src/components/conversation-detail.tsx | 98 ++ .../opencode/src/components/error-state.tsx | 55 ++ .../src/components/model-dropdown.tsx | 40 + .../opencode/src/components/model-picker.tsx | 61 ++ .../src/components/project-picker.tsx | 91 ++ .../opencode/src/components/prompt-form.tsx | 163 ++++ .../opencode/src/components/session-list.tsx | 346 +++++++ extensions/opencode/src/lib/config.ts | 32 + extensions/opencode/src/lib/connection.ts | 37 + extensions/opencode/src/lib/git.ts | 149 +++ extensions/opencode/src/lib/launch.ts | 59 ++ extensions/opencode/src/lib/live-events.ts | 58 ++ extensions/opencode/src/lib/models.ts | 59 ++ .../opencode/src/lib/opencode/client.ts | 234 +++++ .../opencode/src/lib/opencode/discovery.ts | 80 ++ .../opencode/src/lib/opencode/errors.ts | 117 +++ extensions/opencode/src/lib/opencode/types.ts | 28 + .../opencode/src/lib/project-activity.ts | 20 + extensions/opencode/src/lib/prompts.ts | 46 + extensions/opencode/src/lib/session-status.ts | 38 + extensions/opencode/src/lib/time.ts | 15 + extensions/opencode/src/new-session.tsx | 143 +++ extensions/opencode/src/projects.tsx | 179 ++++ extensions/opencode/src/review-changes.tsx | 436 +++++++++ extensions/opencode/src/sessions.tsx | 11 + extensions/opencode/test/actions.test.ts | 91 ++ extensions/opencode/test/config.test.ts | 87 ++ .../opencode/test/conversation-detail.test.ts | 61 ++ extensions/opencode/test/discovery.test.ts | 113 +++ extensions/opencode/test/git.test.ts | 108 +++ extensions/opencode/test/helpers.ts | 75 ++ extensions/opencode/test/models.test.ts | 15 + .../opencode/test/opencode-client.test.ts | 218 +++++ extensions/opencode/test/prompts.test.ts | 43 + .../opencode/test/session-status.test.ts | 47 + extensions/opencode/test/setup.ts | 58 ++ extensions/opencode/tsconfig.json | 17 + 45 files changed, 5157 insertions(+) create mode 100644 extensions/opencode/README.md create mode 100644 extensions/opencode/assets/icon.png create mode 100644 extensions/opencode/assets/icon@dark.png create mode 100644 extensions/opencode/package-lock.json create mode 100644 extensions/opencode/package.json create mode 100644 extensions/opencode/src/ask.tsx create mode 100644 extensions/opencode/src/components/actions.tsx create mode 100644 extensions/opencode/src/components/command-gate.tsx create mode 100644 extensions/opencode/src/components/conversation-detail.tsx create mode 100644 extensions/opencode/src/components/error-state.tsx create mode 100644 extensions/opencode/src/components/model-dropdown.tsx create mode 100644 extensions/opencode/src/components/model-picker.tsx create mode 100644 extensions/opencode/src/components/project-picker.tsx create mode 100644 extensions/opencode/src/components/prompt-form.tsx create mode 100644 extensions/opencode/src/components/session-list.tsx create mode 100644 extensions/opencode/src/lib/config.ts create mode 100644 extensions/opencode/src/lib/connection.ts create mode 100644 extensions/opencode/src/lib/git.ts create mode 100644 extensions/opencode/src/lib/launch.ts create mode 100644 extensions/opencode/src/lib/live-events.ts create mode 100644 extensions/opencode/src/lib/models.ts create mode 100644 extensions/opencode/src/lib/opencode/client.ts create mode 100644 extensions/opencode/src/lib/opencode/discovery.ts create mode 100644 extensions/opencode/src/lib/opencode/errors.ts create mode 100644 extensions/opencode/src/lib/opencode/types.ts create mode 100644 extensions/opencode/src/lib/project-activity.ts create mode 100644 extensions/opencode/src/lib/prompts.ts create mode 100644 extensions/opencode/src/lib/session-status.ts create mode 100644 extensions/opencode/src/lib/time.ts create mode 100644 extensions/opencode/src/new-session.tsx create mode 100644 extensions/opencode/src/projects.tsx create mode 100644 extensions/opencode/src/review-changes.tsx create mode 100644 extensions/opencode/src/sessions.tsx create mode 100644 extensions/opencode/test/actions.test.ts create mode 100644 extensions/opencode/test/config.test.ts create mode 100644 extensions/opencode/test/conversation-detail.test.ts create mode 100644 extensions/opencode/test/discovery.test.ts create mode 100644 extensions/opencode/test/git.test.ts create mode 100644 extensions/opencode/test/helpers.ts create mode 100644 extensions/opencode/test/models.test.ts create mode 100644 extensions/opencode/test/opencode-client.test.ts create mode 100644 extensions/opencode/test/prompts.test.ts create mode 100644 extensions/opencode/test/session-status.test.ts create mode 100644 extensions/opencode/test/setup.ts create mode 100644 extensions/opencode/tsconfig.json diff --git a/extensions/opencode/README.md b/extensions/opencode/README.md new file mode 100644 index 000000000..20a917801 --- /dev/null +++ b/extensions/opencode/README.md @@ -0,0 +1,75 @@ +# OpenCode + +Use [OpenCode](https://opencode.ai/v2) from the launcher: browse and resume sessions, +start new work, ask questions, review changes, and open the TUI in a +terminal. + +This extension does not replace the OpenCode TUI. It covers finding things and +quick actions. OpenCode runs the agent, its tools, its permissions, and the +conversation itself. + +## Requirements + +- [Vicinae](https://vicinae.com) with extension support. +- **OpenCode V2 must be installed separately.** This extension does not provide or bundle OpenCode. + Get the V2 CLI from [opencode.ai/v2](https://opencode.ai/v2/docs) (`curl -fsSL https://opencode.ai/v2/install | bash`). +- A configured OpenCode provider (models run inside OpenCode, not in this extension). + +## Installation + +From the extension store: use the store command in Vicinae and search for "OpenCode". + +Manually: + +```bash +git clone https://github.com/vicinaehq/extensions +cd extensions/opencode +npm install +npm run build +``` + +## OpenCode setup + +The extension talks to an OpenCode V2 server using the official `@opencode/client`: + +1. If OpenCode is running (TUI or background service), it is discovered automatically through the + official service registration. No configuration is needed. +2. If no server is running, the extension offers a **Start OpenCode** action that starts the + background service through the official client mechanism. +3. Remote or explicitly configured servers can be set in the extension preferences. + +## Commands + +| Command | What it does | +| --- | --- | +| **Sessions** | Browse, search, resume, and manage sessions. Each row shows its status (Working, Waiting for Input, Idle, Failed). Search also covers sessions you have not loaded yet, and results load in pages. Actions: Resume in Terminal, Send Prompt, Rename, Delete, Copy Session ID. | +| **Projects** | Browse the projects OpenCode knows, with version control info, session counts, and working badges. Actions: New Session, View Sessions, Open Directory, Open Shell. | +| **Ask** | Ask anything with streaming answers and follow-ups, optionally scoped to a project. Model dropdown, full conversation view. Open the session in the terminal to keep working there. | +| **Review** | Read working tree, staged, or branch diffs per file with syntax highlighting, then ask OpenCode to review or explain them. | +| **New Session** | Start a new session: pick a project, optional prompt, model shown in the form. | + +## Configuration + +| Preference | Default | Description | +| --- | --- | --- | +| Server URL | *(auto-discover)* | Explicit OpenCode server endpoint, e.g. `http://127.0.0.1:49374`. Leave empty for local discovery. | +| Server Username | `opencode` | HTTP basic auth username for a configured server. | +| Server Password | *(empty)* | HTTP basic auth password for a configured server. | +| OpenCode Executable | *(auto-detect)* | OpenCode V2 executable used to open sessions. Auto-detects `opencode2` or `opencode` on PATH and verifies it is a V2 build. | +| Completion Notifications | off | Desktop notification when a session you explicitly waited on finishes. | + +## Development commands + +```bash +npm install # install dependencies +npm run typecheck # tsc --noEmit +npm run test # bun test +npm run lint # vici lint +npm run build # vici build +npm run dev # vici develop +``` + +Tests run with [bun](https://bun.sh). Install it first (`npm test` invokes `bun test`). The tests +cover the helper functions directly and run the OpenCode client against a fake server. Two +test files (`test/config.test.ts`, `test/conversation-detail.test.ts`) stand in for the +Vicinae runtime through `test/setup.ts`. diff --git a/extensions/opencode/assets/icon.png b/extensions/opencode/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..aa09c94784367d1cbca2fba097ffecd04c65b610 GIT binary patch literal 1961 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(AjOvC?e4Rayoc?`*YpJ&XQ@QiU72P)97726-%EK^_d_O_jEqxfeAhD9t4 z3SPsKy7rQB&+Yr~&wpWg;4C+qO(@JJ1E>AUr#sibv~8)qybP0l+XkKxXM|I literal 0 HcmV?d00001 diff --git a/extensions/opencode/assets/icon@dark.png b/extensions/opencode/assets/icon@dark.png new file mode 100644 index 0000000000000000000000000000000000000000..be5f2db620d1b92442f752ebdb4b794cd044d51a GIT binary patch literal 1961 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(AjOvC?e4=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode/client": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.9.tgz", + "integrity": "sha512-WQR2mAtVLy8wFncN5tuI7oQDAkANzg215K3CUzrxIwXjdoVSjfF/iIqHZk+CmimQvO+FQQABQULGHzl0TqDzVA==", + "license": "MIT", + "dependencies": { + "@opencode/protocol": "2.0.9", + "@opencode/schema": "2.0.9" + }, + "peerDependencies": { + "effect": "4.0.0-rc.112", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode/protocol": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.9.tgz", + "integrity": "sha512-mP4EySNWiQ4SnWXBoeCZ6sCsvK/GEzRJivPRQ9eHLTwP91LX99bk4q02KSTWJIZjasQwabHBmny5kE1IV7gTtg==", + "license": "MIT", + "dependencies": { + "@opencode/schema": "2.0.9", + "effect": "4.0.0-rc.112" + } + }, + "node_modules/@opencode/schema": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.9.tgz", + "integrity": "sha512-xSAKHD5D3j5zGPuDYHu4kGy1immmlmzzwImpqfgEAEmnp8jf5312PhG31fcV0VB50YfbP8/uHA+t8zSBkJnrLQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-rc.112" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.4.2.tgz", + "integrity": "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.4.2" + } + }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@vicinae/api": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@vicinae/api/-/api-0.24.0.tgz", + "integrity": "sha512-W18Tw+K4I1+jWYipD6hyFc8V7oZslxTGaANjnszKis+bgOeIJCnHviwyN7EhnQlV4wrXKGGAEGpvv+XhJ+kFMQ==", + "license": "ISC", + "dependencies": { + "@types/react": "19.1.17", + "chokidar": "^4.0.3", + "esbuild": "^0.25.2", + "react": "19.1.1", + "typescript": "5.9.2", + "zod": "^4.0.17" + }, + "bin": { + "vici": "dist/bin.js" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@vicinae/api/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/bun-types": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.4.2.tgz", + "integrity": "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", + "license": "MIT", + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fast-check": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.1.tgz", + "integrity": "sha512-sB5Vghiu8MyCyToHoBVGsT0baZg3sZWNIY+a6Ct2EDrQJlT4YdH6MC1BSLNe3kX1k5i5g0q1O52XFgcKK/rGHg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/opencode/package.json b/extensions/opencode/package.json new file mode 100644 index 000000000..356f909cc --- /dev/null +++ b/extensions/opencode/package.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", + "name": "opencode", + "title": "OpenCode", + "description": "Control OpenCode from the launcher: browse and resume sessions, create new work, ask questions, and review changes. OpenCode stays the coding environment.", + "categories": [ + "Developer Tools", + "Productivity" + ], + "keywords": [ + "opencode", + "ai", + "agent", + "chat", + "terminal", + "developer" + ], + "license": "MIT", + "author": "AdityaZxxx", + "icon": "icon.png", + "commands": [ + { + "name": "sessions", + "title": "Sessions", + "subtitle": "OpenCode", + "description": "Browse, search, resume, and manage OpenCode sessions.", + "mode": "view", + "icon": "icon.png" + }, + { + "name": "projects", + "title": "Projects", + "subtitle": "OpenCode", + "description": "Browse projects known to OpenCode and jump into new or recent work.", + "mode": "view", + "icon": "icon.png" + }, + { + "name": "ask", + "title": "Ask", + "subtitle": "OpenCode", + "description": "Ask OpenCode anything, optionally scoped to a project. Streams answers with follow-ups and model selection.", + "mode": "view", + "icon": "icon.png" + }, + { + "name": "review-changes", + "title": "Review", + "subtitle": "OpenCode", + "description": "Read working tree, staged, or branch diffs per file, then ask OpenCode to review or explain them.", + "mode": "view", + "icon": "icon.png" + }, + { + "name": "new-session", + "title": "New Session", + "subtitle": "OpenCode", + "description": "Start a new OpenCode session: pick a project, enter a prompt, and open it in the terminal.", + "mode": "view", + "icon": "icon.png" + } + ], + "preferences": [ + { + "name": "serverUrl", + "type": "textfield", + "title": "Server URL", + "description": "Explicit OpenCode server endpoint, for example http://127.0.0.1:49374. Leave empty to auto-discover the local OpenCode service.", + "required": false, + "placeholder": "http://127.0.0.1:49374" + }, + { + "name": "serverUsername", + "type": "textfield", + "title": "Server Username", + "description": "HTTP basic auth username for the configured server. Only used when a Server URL is set. Defaults to opencode.", + "required": false, + "default": "opencode" + }, + { + "name": "serverPassword", + "type": "password", + "title": "Server Password", + "description": "HTTP basic auth password for the configured server. Only used when a Server URL is set. Local services discovered automatically handle auth themselves.", + "required": false + }, + { + "name": "openCodePath", + "type": "textfield", + "title": "OpenCode Executable", + "description": "Path or name of the OpenCode V2 executable used to open sessions, for example /usr/local/bin/opencode. Leave empty to auto-detect opencode2 or opencode on PATH.", + "required": false, + "placeholder": "opencode" + }, + { + "name": "notifyOnCompletion", + "type": "checkbox", + "title": "Completion Notifications", + "label": "Notify when a watched session finishes", + "description": "Send a desktop notification when a session you explicitly waited on finishes. Only applies while a Vicinae window is open.", + "required": false, + "default": false + } + ], + "scripts": { + "build": "vici build", + "dev": "vici develop", + "lint": "vici lint", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@opencode/client": "^2.0.9", + "@vicinae/api": "^0.24.0" + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "@types/node": "^24.10.1", + "typescript": "^5.9.3" + } +} diff --git a/extensions/opencode/src/ask.tsx b/extensions/opencode/src/ask.tsx new file mode 100644 index 000000000..42519ce8e --- /dev/null +++ b/extensions/opencode/src/ask.tsx @@ -0,0 +1,402 @@ +import { Action, ActionPanel, Icon, List, Toast, clearSearchBar, showToast, useNavigation } from "@vicinae/api"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useOpenCodeCommand } from "./components/command-gate"; +import type { Config } from "./lib/config"; +import { createOpenCodeService, type OpenCodeService } from "./lib/opencode/client"; +import type { Endpoint } from "./lib/opencode/discovery"; +import { mapOpenCodeError } from "./lib/opencode/errors"; +import type { Project, V2Event } from "./lib/opencode/types"; +import { parseModelRef, modelValue } from "./lib/models"; +import { relativeTime } from "./lib/time"; +import { OpenSessionAction } from "./components/actions"; +import { ModelDropdown, modelLabel } from "./components/model-dropdown"; +import { ModelPickerList } from "./components/model-picker"; +import { ProjectPickerList, basename } from "./components/project-picker"; +import { ConversationDetail, type ConversationMessage, type SharedConversation } from "./components/conversation-detail"; + +const ASK_TITLE_MAX = 60; +const COMPLETION_EVENT_TYPES = new Set([ + "session.idle", + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", +]); + +interface ChatMessage extends ConversationMessage {} + +function eventSessionID(event: V2Event): string | undefined { + const data = (event as { readonly data?: { readonly sessionID?: string } }).data; + return data?.sessionID; +} + +/** Consume the event stream until the session stops producing output. */ +async function consumeStream( + service: OpenCodeService, + sessionID: string, + signal: AbortSignal, + onDelta: (delta: string) => void, +): Promise { + try { + for await (const event of service.events(signal)) { + if (signal.aborted) return; + if (eventSessionID(event) !== sessionID) continue; + if (event.type === "session.text.delta") { + const delta = (event as { readonly data?: { readonly delta?: string } }).data?.delta; + if (delta) onDelta(delta); + } else if (COMPLETION_EVENT_TYPES.has(event.type)) { + return; + } + } + } catch { + // Stream errors are non-fatal; the final text is fetched from the session. + } +} + +/** The authoritative final text of the latest assistant message. */ +async function finalAssistantText(service: OpenCodeService, sessionID: string): Promise { + try { + const messages = await service.messages(sessionID); + const assistant = [...messages].reverse().find((message) => message.type === "assistant"); + if (!assistant) return undefined; + const text = (assistant.content ?? []) + .filter((part) => part.type === "text") + .map((part) => ("text" in part ? part.text : "")) + .join(""); + return text || undefined; + } catch { + return undefined; + } +} + +function firstLine(text: string): string { + const line = text.split("\n", 1)[0] ?? ""; + return line.length > 80 ? `${line.slice(0, 80)}…` : line || "(empty)"; +} + +/** + * Ask: one multi-turn conversation with OpenCode, optionally scoped to a + * project. The search bar is the input and the newest exchange always appears + * right under it, so responses are visible while they stream. Every prompt is + * exactly the text you typed. Changing the project starts a fresh conversation + * in the new scope. Conversations never span projects. Hand off to OpenCode + * when the ask becomes real work. + */ +export function AskView(props: { readonly endpoint: Endpoint; readonly config: Config }): ReactNode { + const { push, pop } = useNavigation(); + const [messages, setMessages] = useState([]); + const [sessionID, setSessionID] = useState(undefined); + const [selectedModelId, setSelectedModelId] = useState(undefined); + const [modelNames, setModelNames] = useState>({}); + const [project, setProject] = useState(undefined); + const [searchText, setSearchText] = useState(""); + const [busy, setBusy] = useState(false); + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + + // Friendly model names for the message tag; falls back to the raw id. + useEffect(() => { + const controller = new AbortController(); + void service + .models() + .then((list) => { + if (controller.signal.aborted) return; + const names: Record = {}; + for (const model of list) names[`${model.providerID}/${model.id}`] = modelLabel(model); + setModelNames(names); + }) + .catch(() => {}); + return () => controller.abort(); + }, [service]); + + const selectedModelName = selectedModelId ? (modelNames[selectedModelId] ?? selectedModelId) : undefined; + + // Shared ref so the pushed conversation detail can always read the latest state. + const sharedRef = useRef({ messages: [], busy: false }); + useEffect(() => { + sharedRef.current = { + messages, + ...(sessionID !== undefined ? { sessionID } : {}), + ...(selectedModelId !== undefined ? { modelId: selectedModelId } : {}), + busy, + }; + }, [busy, messages, selectedModelId, sessionID]); + + // The in-flight stream, aborted when the conversation resets so an old run + // never keeps consuming or leaking into a new one. + const streamControllerRef = useRef(null); + + const appendDelta = useCallback((delta: string) => { + setMessages((previous) => + previous.map((message) => (message.streaming ? { ...message, text: message.text + delta } : message)), + ); + }, []); + + const send = useCallback( + async (rawInput: string) => { + const text = rawInput.trim(); + if (!text || busy) return; + setBusy(true); + const scope = project; + const assistantPlaceholderId = `a${Date.now()}`; + try { + let id = sessionID; + if (!id) { + const model = selectedModelId ? parseModelRef(selectedModelId) : undefined; + const created = await service.createSession({ + ...(scope ? { directory: scope.canonical } : {}), + title: `Ask: ${text.slice(0, ASK_TITLE_MAX)}`, + ...(model ? { model } : {}), + }); + id = created.id; + setSessionID(id); + } + setMessages((previous) => [ + ...previous, + { id: `u${Date.now()}`, role: "user", text, time: Date.now() }, + { id: assistantPlaceholderId, role: "assistant", text: "", time: Date.now(), streaming: true }, + ]); + const controller = new AbortController(); + streamControllerRef.current?.abort(); + streamControllerRef.current = controller; + const streamTask = consumeStream(service, id, controller.signal, appendDelta); + try { + await service.sendPrompt(id, text); + await streamTask; + } finally { + controller.abort(); + if (streamControllerRef.current === controller) streamControllerRef.current = null; + } + const finalText = await finalAssistantText(service, id); + setMessages((previous) => + previous.map((message) => + message.id === assistantPlaceholderId + ? { ...message, text: finalText ?? message.text, streaming: false } + : message, + ), + ); + } catch (error) { + const mapped = mapOpenCodeError(error); + setMessages((previous) => [ + ...previous.filter((message) => !message.streaming), + { id: `e${Date.now()}`, role: "error", text: mapped.message, time: Date.now() }, + ]); + await showToast({ style: Toast.Style.Failure, title: "Ask failed", message: mapped.message }); + } finally { + setBusy(false); + } + }, + [appendDelta, busy, project, selectedModelId, service, sessionID], + ); + + const lastResponse = useMemo( + () => [...messages].reverse().find((message) => message.role === "assistant" && message.text), + [messages], + ); + + const newConversation = useCallback(() => { + streamControllerRef.current?.abort(); + streamControllerRef.current = null; + setMessages([]); + setSessionID(undefined); + setSearchText(""); + }, []); + + /** Scope to a project (or back to none). A live conversation does not continue. It restarts. */ + const scopeTo = useCallback( + (next: Project | undefined) => { + if (sessionID || messages.length > 0) { + streamControllerRef.current?.abort(); + streamControllerRef.current = null; + setMessages([]); + setSessionID(undefined); + setSearchText(""); + if (next) { + void showToast({ + style: Toast.Style.Success, + title: `New conversation in ${basename(next.canonical)}`, + }); + } + } + setProject(next); + }, + [messages.length, sessionID], + ); + + const handleModelChange = useCallback( + (modelId: string) => { + setSelectedModelId(modelId); + const model = parseModelRef(modelId); + if (sessionID && model) void service.switchModel(sessionID, model); + }, + [service, sessionID], + ); + + const stop = useCallback(() => { + if (!sessionID) return; + void service.interrupt(sessionID).catch(() => { + void showToast({ style: Toast.Style.Failure, title: "Failed to stop OpenCode" }); + }); + }, [service, sessionID]); + + const sendCurrent = useCallback(() => { + const text = searchText.trim(); + if (!text) { + void showToast({ title: "Type a prompt first" }); + return; + } + void send(text); + void clearSearchBar(); + }, [searchText, send]); + + const askActions = ( + + + {busy && sessionID ? ( + + ) : null} + + push( + { + scopeTo(picked); + pop(); + }} + navigationTitle="Choose Project" + />, + ) + } + /> + + push( + handleModelChange(modelValue(picked))} + />, + ) + } + /> + {project ? ( + scopeTo(undefined)} + /> + ) : null} + {sessionID ? ( + + push( + , + ) + } + /> + ) : null} + {lastResponse ? ( + + ) : null} + {sessionID ? : null} + {messages.length > 0 ? ( + + ) : null} + + ); + + const visibleMessages = useMemo(() => [...messages].reverse(), [messages]); + + return ( + 0 ? "Ask a follow-up…" : "Ask anything" + } + searchBarAccessory={} + navigationTitle={project ? `Ask · ${basename(project.canonical)}` : "Ask"} + actions={askActions} + > + {visibleMessages.length === 0 ? ( + + ) : ( + visibleMessages.map((message) => ( + + } + actions={askActions} + /> + )) + )} + + ); +} + +export default function AskCommand(): ReactNode { + const command = useOpenCodeCommand(); + if (!command.ready) return command.view; + return ; +} diff --git a/extensions/opencode/src/components/actions.tsx b/extensions/opencode/src/components/actions.tsx new file mode 100644 index 000000000..eb62e034b --- /dev/null +++ b/extensions/opencode/src/components/actions.tsx @@ -0,0 +1,150 @@ +import { Action, ActionPanel, Alert, Keyboard, List, Toast, confirmAlert, showToast } from "@vicinae/api"; +import type { ReactNode } from "react"; +import { mapOpenCodeError, type OpenCodeError } from "../lib/opencode/errors"; +import type { Config } from "../lib/config"; +import { openSessionInTUI, openTUI, openTerminalAt, resolveOpenCodeBinary } from "../lib/launch"; + +/** Show a failure toast for any error thrown by an action. */ +export async function showActionError(error: unknown, fallbackTitle: string): Promise { + const mapped: OpenCodeError = mapOpenCodeError(error); + await showToast({ style: Toast.Style.Failure, title: fallbackTitle, message: mapped.message }); +} + +/** Open the OpenCode TUI resumed at a session. */ +export function OpenSessionAction(props: { + readonly sessionID: string; + readonly directory?: string | undefined; + readonly config: Config; + readonly title?: string | undefined; + readonly shortcut?: Keyboard.Shortcut | Keyboard.Shortcut.Common | undefined; +}): ReactNode { + return ( + { + void (async () => { + try { + const binary = await resolveOpenCodeBinary(props.config.openCodePath); + await openSessionInTUI(binary, props.sessionID, props.directory); + } catch (error) { + await showActionError(error, "Failed to open OpenCode"); + } + })(); + }} + /> + ); +} + +/** Open the OpenCode TUI in a directory. */ +export function OpenTUIAction(props: { readonly directory?: string | undefined; readonly config: Config }): ReactNode { + return ( + { + void (async () => { + try { + const binary = await resolveOpenCodeBinary(props.config.openCodePath); + await openTUI(binary, props.directory); + } catch (error) { + await showActionError(error, "Failed to open OpenCode"); + } + })(); + }} + /> + ); +} + +/** Open a directory in the file browser. */ +export function OpenDirectoryAction(props: { readonly directory: string }): ReactNode { + return ( + + ); +} + +/** Open a bare shell at a directory (no OpenCode TUI). */ +export function OpenTerminalAction(props: { readonly directory: string }): ReactNode { + return ( + { + void openTerminalAt(props.directory).catch(() => showActionError(new Error("No terminal available."), "Failed to open terminal")); + }} + /> + ); +} + +/** Success view shown after a prompt was sent: offer to open the session in OpenCode. */ +export function SentView(props: { + readonly title: string; + readonly description?: string | undefined; + readonly sessionID: string; + readonly directory?: string | undefined; + readonly config: Config; + readonly againTitle?: string | undefined; + readonly onAgain?: (() => void) | undefined; +}): ReactNode { + return ( + + + + {props.onAgain ? ( + + ) : null} + + } + /> + + ); +} + +/** Delete a session after an explicit confirmation. */ +export function DeleteSessionAction(props: { + readonly sessionID: string; + readonly sessionTitle: string; + readonly onDelete: () => Promise; +}): ReactNode { + return ( + { + void (async () => { + const confirmed = await confirmAlert({ + title: `Delete "${props.sessionTitle}"?`, + message: "The session and its child sessions are permanently deleted.", + primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive }, + }); + if (!confirmed) return; + try { + await props.onDelete(); + await showToast({ style: Toast.Style.Success, title: "Session deleted" }); + } catch (error) { + await showActionError(error, "Failed to delete session"); + } + })(); + }} + /> + ); +} diff --git a/extensions/opencode/src/components/command-gate.tsx b/extensions/opencode/src/components/command-gate.tsx new file mode 100644 index 000000000..567e9bde4 --- /dev/null +++ b/extensions/opencode/src/components/command-gate.tsx @@ -0,0 +1,31 @@ +import { List } from "@vicinae/api"; +import { useMemo, type ReactNode } from "react"; +import { loadConfig, type Config } from "../lib/config"; +import { useConnection } from "../lib/connection"; +import type { Endpoint } from "../lib/opencode/discovery"; +import { OpenCodeErrorView } from "./error-state"; + +export type CommandContext = + | { readonly ready: true; readonly config: Config; readonly endpoint: Endpoint } + | { readonly ready: false; readonly view: ReactNode }; + +/** + * Connection gate shared by every command entrypoint: resolves config and + * endpoint once per command window and renders the error view or loading list + * until ready. + */ +export function useOpenCodeCommand(): CommandContext { + const config = useMemo(() => loadConfig(), []); + const connection = useConnection(config); + + if (connection.error) { + return { + ready: false, + view: , + }; + } + if (!connection.endpoint) { + return { ready: false, view: }; + } + return { ready: true, config, endpoint: connection.endpoint }; +} diff --git a/extensions/opencode/src/components/conversation-detail.tsx b/extensions/opencode/src/components/conversation-detail.tsx new file mode 100644 index 000000000..93290dc55 --- /dev/null +++ b/extensions/opencode/src/components/conversation-detail.tsx @@ -0,0 +1,98 @@ +import { Action, ActionPanel, Detail, useNavigation } from "@vicinae/api"; +import { useEffect, useState, type ReactNode, type RefObject } from "react"; +import type { Config } from "../lib/config"; +import type { Endpoint } from "../lib/opencode/discovery"; +import { OpenSessionAction } from "./actions"; + +export interface SharedConversation { + readonly messages: ConversationMessage[]; + readonly sessionID?: string; + readonly modelId?: string; + readonly busy: boolean; +} + +export interface ConversationMessage { + readonly id: string; + readonly role: "user" | "assistant" | "error"; + readonly text: string; + readonly time: number; + readonly streaming?: boolean; +} + +/** Chat-style markdown in natural reading order: oldest at top, newest at bottom. */ +export function conversationMarkdown(messages: ConversationMessage[], busy: boolean): string { + const blocks = messages.map((message) => { + if (message.role === "error") { + return `**Error**\n\n${message.text}`; + } + const author = message.role === "user" ? "**You**" : "**Assistant**"; + const text = message.text || (message.streaming ? "…" : ""); + const suffix = message.streaming ? " ⋯" : ""; + return `${author}${suffix}\n\n${text}`; + }); + if (busy && messages[messages.length - 1]?.role !== "assistant") { + blocks.push("**Assistant** ⋯\n\n…"); + } + return blocks.join("\n\n---\n\n"); +} + +/** + * Full conversation in natural reading order, rendered as chat-style markdown + * with a metadata sidebar. Live-updates while the parent window streams by + * re-reading the shared state ref. + */ +export function ConversationDetail(props: { + readonly shared: RefObject; + readonly config: Config; + readonly endpoint: Endpoint; + readonly onNewConversation: () => void; +}): ReactNode { + const { pop } = useNavigation(); + const [, setTick] = useState(0); + + useEffect(() => { + const timer = setInterval(() => setTick((tick) => tick + 1), 100); + return () => clearInterval(timer); + }, []); + + const state = props.shared.current; + const lastResponse = [...state.messages].reverse().find((message) => message.role === "assistant" && message.text); + + return ( + + + {state.modelId ? : null} + + + + } + actions={ + + + {lastResponse ? ( + + ) : null} + {state.sessionID ? ( + + ) : null} + { + props.onNewConversation(); + }} + /> + + } + /> + ); +} diff --git a/extensions/opencode/src/components/error-state.tsx b/extensions/opencode/src/components/error-state.tsx new file mode 100644 index 000000000..5f4a03a8a --- /dev/null +++ b/extensions/opencode/src/components/error-state.tsx @@ -0,0 +1,55 @@ +import { Action, ActionPanel, List, Toast, showToast } from "@vicinae/api"; +import { useState, type ReactNode } from "react"; +import { errorHint, type OpenCodeError } from "../lib/opencode/errors"; +import { startOpenCodeService } from "../lib/opencode/discovery"; +import { resolveOpenCodeBinary } from "../lib/launch"; +import type { Config } from "../lib/config"; + +/** + * Shared error surface for every command: the not-running state (with a + * Start OpenCode action) or a mapped OpenCode error. + */ +export function OpenCodeErrorView(props: { + readonly error: OpenCodeError; + readonly config: Config; + readonly onRetry?: () => void; +}): ReactNode { + const [starting, setStarting] = useState(false); + const startable = props.error.kind === "unreachable"; + + const start = async () => { + setStarting(true); + const toast = await showToast({ style: Toast.Style.Animated, title: "Starting OpenCode" }); + try { + const binary = await resolveOpenCodeBinary(props.config.openCodePath); + await startOpenCodeService(binary); + await showToast({ style: Toast.Style.Success, title: "OpenCode started" }); + props.onRetry?.(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to start OpenCode."; + await showToast({ style: Toast.Style.Failure, title: "Failed to start OpenCode", message }); + } finally { + setStarting(false); + void toast.hide(); + } + }; + + return ( + + + {startable ? ( + void start()} /> + ) : null} + {props.onRetry ? ( + + ) : null} + + } + /> + + ); +} diff --git a/extensions/opencode/src/components/model-dropdown.tsx b/extensions/opencode/src/components/model-dropdown.tsx new file mode 100644 index 000000000..de7157238 --- /dev/null +++ b/extensions/opencode/src/components/model-dropdown.tsx @@ -0,0 +1,40 @@ +import { List } from "@vicinae/api"; +import { type ReactNode } from "react"; +import { useModels, modelValue } from "../lib/models"; +import type { Endpoint } from "../lib/opencode/discovery"; + +/** + * Model dropdown for the List search bar accessory: the selected model stays + * visible in the window and can be changed inline. Falls back to OpenCode's + * default model when nothing is selected. + */ +export function ModelDropdown(props: { + readonly endpoint: Endpoint; + readonly value?: string | undefined; + readonly onChange: (value: string) => void; +}): ReactNode { + const { models, defaultModelID } = useModels(props.endpoint); + + if (models.length === 0) return null; + + const firstID = models[0] ? modelValue(models[0]) : ""; + const current = props.value ?? defaultModelID ?? firstID; + + return ( + + {models.map((model) => { + const id = modelValue(model); + return ( + + ); + })} + + ); +} + +export { modelLabel } from "./model-picker"; diff --git a/extensions/opencode/src/components/model-picker.tsx b/extensions/opencode/src/components/model-picker.tsx new file mode 100644 index 000000000..a5b2163cf --- /dev/null +++ b/extensions/opencode/src/components/model-picker.tsx @@ -0,0 +1,61 @@ +import { Action, ActionPanel, Icon, List, useNavigation } from "@vicinae/api"; +import { type ReactNode } from "react";import { useModels, modelValue } from "../lib/models"; +import type { Endpoint } from "../lib/opencode/discovery"; +import type { ModelRef } from "../lib/opencode/types"; + +/** Friendly display name for a model. */ +export function modelLabel(model: { readonly name?: string; readonly providerID: string; readonly id: string }): string { + return model.name || `${model.providerID}/${model.id}`; +} + +/** Model picker backed by the official model API. Used inside advanced actions. */ +export function ModelPickerList(props: { + readonly endpoint: Endpoint; + readonly onPick: (model: ModelRef) => void; +}): ReactNode { + const { pop } = useNavigation(); + const { models, defaultModelID, failed } = useModels(props.endpoint); + + if (failed) { + return ( + + + + ); + } + + return ( + + {models.map((model) => { + const ref: ModelRef = { providerID: model.providerID, id: model.id }; + const id = modelValue(model); + return ( + + { + props.onPick(ref); + pop(); + }} + /> + + } + /> + ); + })} + + ); +} diff --git a/extensions/opencode/src/components/project-picker.tsx b/extensions/opencode/src/components/project-picker.tsx new file mode 100644 index 000000000..09e11e90c --- /dev/null +++ b/extensions/opencode/src/components/project-picker.tsx @@ -0,0 +1,91 @@ +import { Action, ActionPanel, Icon, List } from "@vicinae/api"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { createOpenCodeService } from "../lib/opencode/client"; +import type { Endpoint } from "../lib/opencode/discovery"; +import { mapOpenCodeError, type OpenCodeError } from "../lib/opencode/errors"; +import type { Project, SessionInfo } from "../lib/opencode/types"; +import { activityByProject } from "../lib/project-activity"; +import { relativeTime } from "../lib/time"; + +const GLOBAL_DIRECTORY = "/"; + +export function basename(directory: string): string { + const parts = directory.split("/").filter(Boolean); + return parts[parts.length - 1] ?? directory; +} + +/** + * Project picker backed by the official project API, sorted by recent + * activity. Used as the first step of the Review and New Session flows, + * and as the project scope picker inside Ask. + */ +export function ProjectPickerList(props: { + readonly endpoint: Endpoint; + readonly onPick: (project: Project) => void; + readonly navigationTitle: string; +}): ReactNode { + const [projects, setProjects] = useState(undefined); + const [sessions, setSessions] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + const service = createOpenCodeService(props.endpoint); + void (async () => { + try { + const [projectList, page] = await Promise.all([ + service.projects(), + service.sessions({ limit: 100 }).catch(() => null), + ]); + if (controller.signal.aborted) return; + setProjects(projectList); + if (page) setSessions(page.data); + } catch (caught) { + if (controller.signal.aborted) return; + setError(mapOpenCodeError(caught)); + } + })(); + return () => controller.abort(); + }, [props.endpoint]); + + const activity = useMemo(() => activityByProject(sessions), [sessions]); + const sorted = useMemo(() => { + return (projects ?? []) + .filter((project) => project.canonical !== GLOBAL_DIRECTORY) + .sort((a, b) => (activity.get(b.id)?.last ?? 0) - (activity.get(a.id)?.last ?? 0)); + }, [projects, activity]); + + if (error) { + return ( + + + + ); + } + + return ( + + {sorted.map((project) => ( + + props.onPick(project)} /> + + } + /> + ))} + + ); +} diff --git a/extensions/opencode/src/components/prompt-form.tsx b/extensions/opencode/src/components/prompt-form.tsx new file mode 100644 index 000000000..f0e8efa73 --- /dev/null +++ b/extensions/opencode/src/components/prompt-form.tsx @@ -0,0 +1,163 @@ +import { Action, ActionPanel, Form, Toast, showToast, useNavigation } from "@vicinae/api"; +import { useMemo, useState, type ReactNode } from "react"; +import { createOpenCodeService } from "../lib/opencode/client"; +import type { Endpoint } from "../lib/opencode/discovery"; +import type { Config } from "../lib/config"; +import type { SessionInfo } from "../lib/opencode/types"; +import { isSameModelRef, modelValue, parseModelRef, useModels } from "../lib/models"; +import { showActionError, OpenSessionAction, SentView } from "./actions"; +import { ModelPickerList } from "./model-picker"; +import { modelLabel } from "./model-dropdown"; + +/** Form shown after "Send Prompt" on a session, with model selection. */ +export function PromptForm(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly session: SessionInfo; +}): ReactNode { + const { push } = useNavigation(); + const [sent, setSent] = useState(false); + const [model, setModel] = useState(undefined); + const { models, defaultModelID } = useModels(props.endpoint); + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + + const known = (value: string | undefined): boolean => + value !== undefined && models.some((model) => modelValue(model) === value); + const sessionValue = props.session.model ? modelValue(props.session.model) : undefined; + const firstValue = models[0] ? modelValue(models[0]) : undefined; + const initialModel = known(sessionValue) + ? sessionValue + : known(defaultModelID) + ? defaultModelID + : firstValue; + + // Explicitly chosen model, else the session or default model. + const activeModel = model ?? initialModel; + + const handleSubmit = async (values: { readonly prompt?: string }) => { + const text = (values.prompt ?? "").trim(); + if (!text) return; + try { + // Sending with a different model switches the session first. This + // matches picking a model in the OpenCode TUI. + const selected = activeModel ? parseModelRef(activeModel) : undefined; + if (selected && !isSameModelRef(selected, props.session.model ?? undefined)) { + await service.switchModel(props.session.id, selected); + } + await service.sendPrompt(props.session.id, text); + } catch (error) { + await showActionError(error, "Failed to send prompt"); + return; + } + setSent(true); + }; + + if (sent) { + return ( + + ); + } + + return ( +
+ + + push( + setModel(modelValue(picked))} + />, + ) + } + /> + + + } + > + + + {models.length > 0 && activeModel ? ( + + {models.map((model) => { + const value = modelValue(model); + return ( + + ); + })} + + ) : sessionValue ? ( + + ) : null} + + ); +} + +/** Form shown after "Rename" on a session. */ +export function RenameSessionForm(props: { + readonly endpoint: Endpoint; + readonly session: SessionInfo; + readonly onRenamed: () => void; +}): ReactNode { + const { pop } = useNavigation(); + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + + const handleSubmit = async (values: { readonly title?: string }) => { + const title = (values.title ?? "").trim(); + if (!title) return; + try { + await service.renameSession(props.session.id, title); + } catch (error) { + await showActionError(error, "Failed to rename session"); + return; + } + await showToast({ style: Toast.Style.Success, title: "Session renamed" }); + props.onRenamed(); + pop(); + }; + + return ( +
+ + + } + > + + + ); +} diff --git a/extensions/opencode/src/components/session-list.tsx b/extensions/opencode/src/components/session-list.tsx new file mode 100644 index 000000000..46a900cb2 --- /dev/null +++ b/extensions/opencode/src/components/session-list.tsx @@ -0,0 +1,346 @@ +import { Action, ActionPanel, Icon, List, Toast, showToast, sendDesktopNotification, useNavigation } from "@vicinae/api"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { createOpenCodeService, type OpenCodeService } from "../lib/opencode/client"; +import type { Endpoint } from "../lib/opencode/discovery"; +import { mapOpenCodeError, type OpenCodeError } from "../lib/opencode/errors"; +import type { Config } from "../lib/config"; +import { sessionStatus, statusLabel, type SessionStatusKind } from "../lib/session-status"; +import { relativeTime } from "../lib/time"; +import type { SessionActive, SessionInfo } from "../lib/opencode/types"; +import { useLiveEvents } from "../lib/live-events"; +import { DeleteSessionAction, OpenSessionAction, showActionError } from "./actions"; +import { PromptForm, RenameSessionForm } from "./prompt-form"; +import { OpenCodeErrorView } from "./error-state"; + +const BASE_PAGE_SIZE = 100; +const SEARCH_PAGE_SIZE = 50; +const SEARCH_MIN_LENGTH = 2; +const SEARCH_DEBOUNCE_MS = 250; + +function statusAccessory(kind: SessionStatusKind): List.Item.Accessory { + const color: Record = { + working: "#f5a623", + waiting: "#e0405c", + idle: "#7a7a7a", + failed: "#e0405c", + }; + return { tag: { value: statusLabel(kind), color: color[kind] } }; +} + +function statusIcon(kind: SessionStatusKind) { + switch (kind) { + case "working": + return Icon.CircleProgress; + case "waiting": + return Icon.Warning; + case "failed": + return Icon.XMarkCircle; + case "idle": + return Icon.SpeechBubble; + } +} + +function sessionDetailMarkdown(session: SessionInfo): string { + const lines = [ + `**${session.title || "Untitled session"}**`, + "", + `- Directory: \`${session.location?.directory ?? "unknown"}\``, + `- Agent: ${session.agent ?? "default"}`, + `- Model: ${session.model ? `${session.model.providerID}/${session.model.id}` : "default"}`, + `- Updated: ${relativeTime(session.time.updated) ?? "unknown"}`, + ]; + if (session.outcome) lines.push(`- Last outcome: ${session.outcome}`); + if (session.cost) lines.push(`- Cost: $${session.cost.toFixed(4)}`); + if (session.tokens) lines.push(`- Tokens: ${Math.round(session.tokens.input + session.tokens.output)}`); + lines.push("", `Session ID: \`${session.id}\``); + return lines.join("\n"); +} + +/** Wait for a running session to finish, then report completion. */ +function WaitForCompletionAction(props: { + readonly sessionID: string; + readonly service: OpenCodeService; + readonly notifyOnCompletion: boolean; + readonly onChanged: () => void; +}): ReactNode { + return ( + { + void (async () => { + const toast = await showToast({ style: Toast.Style.Animated, title: "Waiting for OpenCode" }); + try { + await props.service.wait(props.sessionID); + await showToast({ style: Toast.Style.Success, title: "OpenCode session finished" }); + if (props.notifyOnCompletion) { + await sendDesktopNotification({ + title: "OpenCode session finished", + body: "The session you were waiting on has finished.", + }); + } + props.onChanged(); + } catch (error) { + await showActionError(error, "Wait failed"); + } finally { + void toast.hide(); + } + })(); + }} + /> + ); +} + +function SessionActions(props: { + readonly session: SessionInfo; + readonly status: SessionStatusKind; + readonly config: Config; + readonly endpoint: Endpoint; + readonly service: OpenCodeService; + readonly onChanged: () => void; +}): ReactNode { + const { push } = useNavigation(); + return ( + + + + push() + } + /> + {props.status === "working" ? ( + + ) : null} + + push( + , + ) + } + /> + + { + await props.service.deleteSession(props.session.id); + props.onChanged(); + }} + /> + + ); +} + +/** + * Shared session browser. Used by the OpenCode Sessions command and pushed + * from the Projects view with a project filter. One source of truth: the + * OpenCode server. Local filtering gives instant feedback while the + * (debounced) server search covers sessions beyond the loaded pages. + */ +export function SessionListView(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly projectID?: string; + readonly navigationTitle: string; +}): ReactNode { + const [searchText, setSearchText] = useState(""); + const [sessions, setSessions] = useState([]); + const [cursor, setCursor] = useState(undefined); + const [searchActive, setSearchActive] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [active, setActive] = useState>({}); + const [waiting, setWaiting] = useState>(new Set()); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + + const query = searchText.trim(); + const queryRef = useRef(query); + useEffect(() => { + queryRef.current = query; + }, [query]); + const controllerRef = useRef(null); + const searchTimerRef = useRef | null>(null); + + const statusInputs = useMemo(() => ({ active, waiting }), [active, waiting]); + + const reloadFor = useCallback( + async (targetQuery: string, signal?: AbortSignal) => { + setIsLoading(true); + try { + if (targetQuery.length >= SEARCH_MIN_LENGTH) { + const page = await service.sessions({ + limit: SEARCH_PAGE_SIZE, + search: targetQuery, + ...(props.projectID ? { projectID: props.projectID } : {}), + }); + if (signal?.aborted) return; + setSessions(page.data); + setCursor(undefined); + setSearchActive(true); + } else { + const page = await service.sessions({ + limit: BASE_PAGE_SIZE, + ...(props.projectID ? { projectID: props.projectID } : {}), + }); + if (signal?.aborted) return; + setSessions(page.data); + setCursor(page.next ?? undefined); + setSearchActive(false); + } + const [activeMap, permissions, forms] = await Promise.all([ + service.activeSessions().catch(() => ({})), + service.pendingPermissions().catch(() => new Set()), + service.pendingForms().catch(() => new Set()), + ]); + if (signal?.aborted) return; + setActive(activeMap); + setWaiting(new Set([...permissions, ...forms])); + setError(null); + } catch (caught) { + if (signal?.aborted) return; + setError(mapOpenCodeError(caught)); + } finally { + if (!signal?.aborted) setIsLoading(false); + } + }, + [service, props.projectID], + ); + + const loadLatest = useCallback( + (targetQuery: string) => { + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + void reloadFor(targetQuery, controller.signal); + }, + [reloadFor], + ); + + const refreshCallback = useCallback(() => { + loadLatest(queryRef.current); + }, [loadLatest]); + + useEffect(() => { + loadLatest(queryRef.current); + return () => controllerRef.current?.abort(); + }, [loadLatest, reloadKey]); + + useEffect(() => { + if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + searchTimerRef.current = null; + loadLatest(queryRef.current); + }, SEARCH_DEBOUNCE_MS); + return () => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = null; + } + }; + }, [searchText, loadLatest]); + + // Live refresh through the official event stream while this view is mounted. + useLiveEvents(service, refreshCallback); + + if (error) { + return setReloadKey((v) => v + 1)} />; + } + + const loadMore = async () => { + if (!cursor || isLoading || searchActive) return; + setIsLoading(true); + try { + const page = await service.sessions({ + limit: BASE_PAGE_SIZE, + cursor, + ...(props.projectID ? { projectID: props.projectID } : {}), + }); + setSessions((previous) => [ + ...previous, + ...page.data.filter((session) => !previous.some((existing) => existing.id === session.id)), + ]); + setCursor(page.next ?? undefined); + } catch (caught) { + await showActionError(caught, "Failed to load more sessions"); + } finally { + setIsLoading(false); + } + }; + + return ( + void loadMore() }} + > + {sessions.length === 0 && !isLoading ? ( + + ) : ( + sessions.map((session) => { + const status = sessionStatus(session, statusInputs); + return ( + } + actions={ + setReloadKey((v) => v + 1)} + /> + } + /> + ); + }) + )} + + ); +} diff --git a/extensions/opencode/src/lib/config.ts b/extensions/opencode/src/lib/config.ts new file mode 100644 index 000000000..162059dd5 --- /dev/null +++ b/extensions/opencode/src/lib/config.ts @@ -0,0 +1,32 @@ +import { getPreferenceValues } from "@vicinae/api"; + +export interface ExtensionPreferences { + readonly serverUrl?: string; + readonly serverUsername?: string; + readonly serverPassword?: string; + readonly openCodePath?: string; + readonly notifyOnCompletion?: boolean; +} + +export interface Config { + readonly serverUrl?: string; + readonly serverUsername: string; + readonly serverPassword?: string; + readonly openCodePath?: string; + readonly notifyOnCompletion: boolean; +} + +/** Load and normalize extension preferences. Sensible defaults, nothing required. */ +export function loadConfig(): Config { + const preferences = getPreferenceValues(); + const serverUrl = preferences.serverUrl?.trim(); + const serverPassword = preferences.serverPassword?.trim(); + const openCodePath = preferences.openCodePath?.trim(); + return { + ...(serverUrl ? { serverUrl } : {}), + serverUsername: preferences.serverUsername?.trim() || "opencode", + ...(serverPassword ? { serverPassword } : {}), + ...(openCodePath ? { openCodePath } : {}), + notifyOnCompletion: preferences.notifyOnCompletion ?? false, + }; +} diff --git a/extensions/opencode/src/lib/connection.ts b/extensions/opencode/src/lib/connection.ts new file mode 100644 index 000000000..f038292be --- /dev/null +++ b/extensions/opencode/src/lib/connection.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import { mapOpenCodeError, notRunningError, type OpenCodeError } from "./opencode/errors"; +import { resolveEndpoint, type ConnectionConfig, type Endpoint } from "./opencode/discovery"; + +export interface Connection { + readonly endpoint?: Endpoint; + readonly error?: OpenCodeError; + readonly retry: () => void; +} + +/** + * Resolve the OpenCode endpoint for the lifetime of a command window. + * A null discovery result becomes the "OpenCode is not running" state. + */ +export function useConnection(config: ConnectionConfig): Connection { + const [state, setState] = useState<{ endpoint?: Endpoint; error?: OpenCodeError }>({}); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + let cancelled = false; + resolveEndpoint(config).then( + (endpoint) => { + if (cancelled) return; + setState(endpoint ? { endpoint } : { error: notRunningError() }); + }, + (error: unknown) => { + if (cancelled) return; + setState({ error: mapOpenCodeError(error) }); + }, + ); + return () => { + cancelled = true; + }; + }, [config, attempt]); + + return { ...state, retry: () => setAttempt((value) => value + 1) }; +} diff --git a/extensions/opencode/src/lib/git.ts b/extensions/opencode/src/lib/git.ts new file mode 100644 index 000000000..e21ef6fd7 --- /dev/null +++ b/extensions/opencode/src/lib/git.ts @@ -0,0 +1,149 @@ +import { execFile } from "node:child_process"; + +/** + * Read-only local git access for the Review Changes command. + * These helpers run `git diff` and `git status` only. They never stage, + * commit, or otherwise modify the repository. + */ + +import type { ReviewScope } from "./prompts"; + +export type { ReviewScope }; + +export interface ChangedFile { + readonly path: string; + /** Added lines, or -1 when unknown (untracked file, binary). */ + readonly additions: number; + /** Deleted lines, or -1 when unknown (untracked file, binary). */ + readonly deletions: number; + readonly untracked: boolean; +} + +export interface FileDiff { + readonly path: string; + readonly diff: string; + readonly truncated: boolean; +} + +/** Per-file diff cap: a launcher window is for reading, not scrolling. */ +export const MAX_DIFF_LINES = 300; + +const GIT_TIMEOUT_MS = 10_000; +const GIT_MAX_BUFFER = 10 * 1024 * 1024; + +/** Runs git with the given args; resolves stdout. Injectable for tests. */ +export type GitRunner = (args: readonly string[]) => Promise; + +export function createGitRunner(directory: string): GitRunner { + return (args) => + new Promise((resolve, reject) => { + execFile( + "git", + [...args], + { cwd: directory, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }, + (error, stdout) => { + if (error) { + // `git diff --no-index` exits 1 when differences exist; stdout still holds the diff. + if ((error as { code?: unknown }).code === 1 && stdout) { + resolve(stdout); + return; + } + reject(error instanceof Error ? error : new Error("git command failed")); + return; + } + resolve(stdout); + }, + ); + }); +} + +export async function checkGit(runner: GitRunner): Promise { + try { + await runner(["--version"]); + return true; + } catch { + return false; + } +} + +/** + * Base diff args for a scope. Branch scope diffs `base...HEAD` and requires + * the base ref. Without it there is nothing meaningful to diff. The caller + * gets `undefined` and should hide that scope. + */ +export function scopeDiffArgs(scope: ReviewScope, base: string | undefined): readonly string[] | undefined { + if (scope === "staged") return ["diff", "--no-color", "--cached"]; + if (scope === "branch") { + if (!base) return undefined; + return ["diff", "--no-color", `${base}...HEAD`]; + } + return ["diff", "--no-color"]; +} + +export function parseNumstat(output: string): ChangedFile[] { + const files: ChangedFile[] = []; + for (const line of output.split("\n")) { + if (!line.trim()) continue; + const columns = line.split("\t"); + if (columns.length < 3) continue; + const [addedRaw, deletedRaw, ...rest] = columns as [string, string, ...string[]]; + const additions = addedRaw === "-" ? -1 : Number.parseInt(addedRaw, 10); + const deletions = deletedRaw === "-" ? -1 : Number.parseInt(deletedRaw, 10); + if (Number.isNaN(additions) || Number.isNaN(deletions)) continue; + files.push({ path: rest.join("\t"), additions, deletions, untracked: false }); + } + return files; +} + +/** Parse NUL-separated `git status --porcelain=v1 -z` output for untracked (`??`) paths. */ +export function parsePorcelainUntracked(output: string): string[] { + const paths: string[] = []; + for (const entry of output.split("\0")) { + if (entry.startsWith("?? ")) paths.push(entry.slice(3)); + } + return paths; +} + +export async function listChangedFiles( + runner: GitRunner, + scope: ReviewScope, + base?: string, +): Promise { + const baseArgs = scopeDiffArgs(scope, base); + if (!baseArgs) return []; + const files = parseNumstat(await runner([...baseArgs, "--numstat", "--"])); + if (scope === "working") { + // `git diff` omits untracked files; a new file with secrets in it is + // exactly what a review should catch, so include them via status. + const porcelain = await runner(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--"]); + const known = new Set(files.map((file) => file.path)); + for (const path of parsePorcelainUntracked(porcelain)) { + if (!known.has(path)) files.push({ path, additions: -1, deletions: -1, untracked: true }); + } + } + return files; +} + +export async function loadFileDiff( + runner: GitRunner, + scope: ReviewScope, + file: ChangedFile, + base?: string, +): Promise { + const args = file.untracked + ? ["diff", "--no-color", "--no-index", "--", "/dev/null", file.path] + : [...(scopeDiffArgs(scope, base) ?? ["diff", "--no-color"]), "--", file.path]; + const raw = await runner(args); + const lines = raw.split("\n"); + if (lines.length > MAX_DIFF_LINES) { + return { path: file.path, diff: lines.slice(0, MAX_DIFF_LINES).join("\n"), truncated: true }; + } + return { path: file.path, diff: raw.trimEnd(), truncated: false }; +} + +/** Compact change summary for list accessories: "+12 -3", "new", or "binary". */ +export function formatChangeStat(file: ChangedFile): string { + if (file.untracked) return "new"; + if (file.additions < 0 || file.deletions < 0) return "binary"; + return `+${file.additions} -${file.deletions}`; +} diff --git a/extensions/opencode/src/lib/launch.ts b/extensions/opencode/src/lib/launch.ts new file mode 100644 index 000000000..f06722bf1 --- /dev/null +++ b/extensions/opencode/src/lib/launch.ts @@ -0,0 +1,59 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { runInTerminal } from "@vicinae/api"; +import { OpenCodeError } from "./opencode/errors"; + +const execFileAsync = promisify(execFile); + +/** Matches OpenCode V2 version output such as `opencode v2.0.8` or `2.0.8`. */ +const V2_VERSION_PATTERN = /(^|\s)v?2\.\d+/; + +async function isV2Binary(name: string): Promise { + try { + const { stdout } = await execFileAsync(name, ["--version"], { timeout: 5_000 }); + return V2_VERSION_PATTERN.test(stdout.trim()); + } catch { + return false; + } +} + +/** + * Resolve the OpenCode V2 executable used to open the TUI. + * + * An explicitly configured path wins; otherwise `opencode2` then `opencode` + * are probed on PATH and verified to be a V2 build. + */ +export async function resolveOpenCodeBinary(configured?: string): Promise { + for (const candidate of configured ? [configured] : ["opencode2", "opencode"]) { + if (await isV2Binary(candidate)) return candidate; + } + if (configured) { + throw new OpenCodeError( + "not-installed", + "The configured OpenCode executable is not OpenCode V2.", + `openCodePath: ${configured}`, + ); + } + throw new OpenCodeError("not-installed", "OpenCode V2 executable not found."); +} + +/** Open the OpenCode TUI resumed at a specific session. */ +export async function openSessionInTUI(binary: string, sessionID: string, directory?: string): Promise { + const args = directory ? [binary, "--session", sessionID, directory] : [binary, "--session", sessionID]; + await runInTerminal(args, { hold: true }); +} + +/** Open the OpenCode TUI in an optional directory. */ +export async function openTUI(binary: string, directory?: string): Promise { + await runInTerminal(directory ? [binary, directory] : [binary], { hold: true }); +} + +function posixQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +/** Open a terminal window whose working directory is `directory`. */ +export async function openTerminalAt(directory: string): Promise { + const command = `cd -- ${posixQuote(directory)} && exec "$SHELL"`; + await runInTerminal(["sh", "-c", command], { hold: true }); +} diff --git a/extensions/opencode/src/lib/live-events.ts b/extensions/opencode/src/lib/live-events.ts new file mode 100644 index 000000000..53aa12efb --- /dev/null +++ b/extensions/opencode/src/lib/live-events.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import type { OpenCodeService } from "./opencode/client"; + +const EVENT_REFRESH_DEBOUNCE_MS = 1000; + +/** Session lifecycle and user input events that should refresh a live view. */ +const LIVE_EVENT_TYPES = new Set([ + "session.created", + "session.renamed", + "session.deleted", + "session.status", + "session.idle", + "session.permissions", + "session.execution.started", + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "permission.asked", + "permission.replied", + "form.created", + "form.replied", + "form.cancelled", +]); + +/** + * Live refresh through the official event stream while a view is mounted. + * Session lifecycle events trigger a debounced `refresh` call; other events + * and stream failures are ignored. + */ +export function useLiveEvents(service: OpenCodeService, refresh: () => void): void { + const timerRef = useRef | null>(null); + + useEffect(() => { + const controller = new AbortController(); + void (async () => { + try { + for await (const event of service.events(controller.signal)) { + if (controller.signal.aborted) return; + if (!LIVE_EVENT_TYPES.has(event.type)) continue; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + refresh(); + }, EVENT_REFRESH_DEBOUNCE_MS); + } + } catch { + // Event stream failures are non-fatal; the view still works. + } + })(); + return () => { + controller.abort(); + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [service, refresh]); +} diff --git a/extensions/opencode/src/lib/models.ts b/extensions/opencode/src/lib/models.ts new file mode 100644 index 000000000..a8cdba887 --- /dev/null +++ b/extensions/opencode/src/lib/models.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { createOpenCodeService } from "./opencode/client"; +import type { Endpoint } from "./opencode/discovery"; +import type { ModelInfo, ModelRef } from "./opencode/types"; + +/** Canonical "providerID/modelID" dropdown value for a model reference. */ +export function modelValue(model: Pick): string { + return `${model.providerID}/${model.id}`; +} + +/** Split a "providerID/modelID" dropdown value back into a model reference. */ +export function parseModelRef(value: string): ModelRef | undefined { + const slash = value.indexOf("/"); + if (slash <= 0 || slash === value.length - 1) return undefined; + return { providerID: value.slice(0, slash), id: value.slice(slash + 1) }; +} + +export function isSameModelRef(a: ModelRef | undefined, b: ModelRef | undefined): boolean { + if (!a || !b) return false; + return a.providerID === b.providerID && a.id === b.id; +} + +export interface ModelsState { + readonly models: ModelInfo[]; + readonly defaultModelID: string | undefined; + /** The model list could not be loaded. */ + readonly failed: boolean; +} + +const EMPTY_MODELS: ModelsState = { models: [], defaultModelID: undefined, failed: false }; + +/** Load the model list and OpenCode's default model for the lifetime of a view. */ +export function useModels(endpoint: Endpoint): ModelsState { + const [state, setState] = useState(EMPTY_MODELS); + + useEffect(() => { + const controller = new AbortController(); + const service = createOpenCodeService(endpoint); + void (async () => { + try { + const [list, fallback] = await Promise.all([ + service.models(), + service.defaultModel().catch(() => null), + ]); + if (controller.signal.aborted) return; + setState({ + models: list, + defaultModelID: fallback ? modelValue(fallback) : undefined, + failed: false, + }); + } catch { + if (!controller.signal.aborted) setState({ ...EMPTY_MODELS, failed: true }); + } + })(); + return () => controller.abort(); + }, [endpoint]); + + return state; +} diff --git a/extensions/opencode/src/lib/opencode/client.ts b/extensions/opencode/src/lib/opencode/client.ts new file mode 100644 index 000000000..a5d7e691c --- /dev/null +++ b/extensions/opencode/src/lib/opencode/client.ts @@ -0,0 +1,234 @@ +import { OpenCode, type OpenCodeClient } from "@opencode/client"; +import { mapOpenCodeError } from "./errors"; +import type { Endpoint } from "./discovery"; +import type { + ModelInfo, + ModelRef, + Project, + ServerInfo, + SessionActive, + SessionInfo, + SessionMessageInfo, + V2Event, + VcsInfo, +} from "./types"; + +const DEFAULT_TIMEOUT_MS = 10_000; +const PROMPT_TIMEOUT_MS = 30_000; + +/** One page of sessions, newest-first unless `cursor` says otherwise. */ +export interface SessionPage { + readonly data: SessionInfo[]; + readonly next?: string | null; +} + +export interface SessionListQuery { + readonly limit?: number; + readonly search?: string; + readonly cursor?: string; + readonly projectID?: string; +} + +export interface CreateSessionInput { + readonly title?: string; + readonly directory?: string; + readonly agent?: string; + readonly model?: ModelRef; +} + +/** + * The only surface the UI is allowed to talk to. Backed by the official + * OpenCode V2 client; the UI never sees raw HTTP details. + */ +export interface OpenCodeService { + info(): Promise; + projects(): Promise; + sessions(query?: SessionListQuery): Promise; + /** All sessions, every page: triage must not miss failures beyond page one. */ + allSessions(query?: SessionListQuery): Promise; + session(id: string): Promise; + activeSessions(): Promise>; + createSession(input: CreateSessionInput): Promise; + sendPrompt(sessionID: string, text: string): Promise; + renameSession(id: string, title: string): Promise; + deleteSession(id: string): Promise; + wait(sessionID: string, signal?: AbortSignal): Promise; + interrupt(sessionID: string): Promise; + models(): Promise; + defaultModel(): Promise; + switchModel(sessionID: string, model: ModelRef): Promise; + vcs(directory: string): Promise; + messages(sessionID: string): Promise; + pendingPermissions(): Promise>; + pendingForms(): Promise>; + events(signal?: AbortSignal): AsyncIterable; +} + +interface CallOptions { + readonly timeoutMs?: number | undefined; + readonly signal?: AbortSignal | undefined; +} + +type CallFn = (client: OpenCodeClient, requestOptions?: { signal?: AbortSignal }) => Promise; + +function composeSignals(timeoutMs: number | undefined, signal?: AbortSignal | undefined): AbortSignal | undefined { + const timeout = timeoutMs != null ? AbortSignal.timeout(timeoutMs) : undefined; + if (timeout && signal) return AbortSignal.any([signal, timeout]); + return timeout ?? signal; +} + +/** + * Run one call against the OpenCode API. + * + * A client is created per call with a fetch wrapper that observes the response + * status, so API failures (401, 404, ...) map to precise error kinds even when + * the error body is not JSON. No shared mutable state, no retries. + */ +async function call( + endpoint: Endpoint, + fn: CallFn, + options?: CallOptions, + fetchImpl?: typeof globalThis.fetch, +): Promise { + let failedStatus: number | undefined; + const baseFetch = fetchImpl ?? globalThis.fetch.bind(globalThis); + const client = OpenCode.make({ + baseUrl: endpoint.url, + ...(endpoint.headers ? { headers: endpoint.headers } : {}), + fetch: Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await baseFetch(input, init); + if (!response.ok) failedStatus = response.status; + return response; + }, + { preconnect: () => {} }, + ), + }); + const timeoutMs = options?.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs; + const signal = composeSignals(timeoutMs, options?.signal); + const requestOptions = signal ? { signal } : undefined; + try { + return await fn(client, requestOptions); + } catch (error) { + throw mapOpenCodeError(error, failedStatus); + } +} + +export function createOpenCodeService( + endpoint: Endpoint, + injected?: { readonly fetch?: typeof globalThis.fetch }, +): OpenCodeService { + const invoke = (fn: CallFn, options?: CallOptions): Promise => + call(endpoint, fn, options, injected?.fetch); + + const sessions: OpenCodeService["sessions"] = (query) => + invoke( + (client, options) => + client.session.list( + { + ...(query?.limit != null ? { limit: query.limit } : {}), + ...(query?.search ? { search: query.search } : {}), + ...(query?.cursor ? { cursor: query.cursor } : {}), + ...(query?.projectID ? { project: query.projectID } : {}), + }, + options, + ), + { timeoutMs: PROMPT_TIMEOUT_MS }, + ).then((page) => ({ data: page.data, next: page.cursor?.next ?? null })); + + return { + info: () => invoke((client, options) => client.server.info(options)), + + projects: () => invoke((client, options) => client.project.list(options)), + + sessions, + + allSessions: async (query) => { + const first = await sessions(query); + if (!first.next) return first; + const data = [...first.data]; + let cursor: string | null = first.next; + while (cursor) { + const page = await sessions({ ...query, cursor }); + data.push(...page.data.filter((session) => !data.some((existing) => existing.id === session.id))); + cursor = page.next ?? null; + } + return { data, next: null }; + }, + + session: (id) => invoke((client, options) => client.session.get({ sessionID: id }, options)), + + activeSessions: () => invoke((client, options) => client.session.active(options)), + + createSession: (input) => + invoke( + (client, options) => + client.session.create( + { + ...(input.title ? { title: input.title } : {}), + ...(input.directory ? { location: { directory: input.directory } } : {}), + ...(input.agent ? { agent: input.agent } : {}), + ...(input.model ? { model: input.model } : {}), + }, + options, + ), + { timeoutMs: PROMPT_TIMEOUT_MS }, + ), + + sendPrompt: (sessionID, text) => + invoke((client, options) => client.session.prompt({ sessionID, text }, options), { + timeoutMs: PROMPT_TIMEOUT_MS, + }).then(() => undefined), + + renameSession: (id, title) => + invoke((client, options) => client.session.update({ sessionID: id, title }, options)), + + deleteSession: (id) => invoke((client, options) => client.session.remove({ sessionID: id }, options)), + + wait: (sessionID, signal) => + invoke((client, options) => client.session.wait({ sessionID }, options), { + timeoutMs: undefined, + signal, + }), + + interrupt: (sessionID) => + invoke((client, options) => client.session.interrupt({ sessionID }, options)).then(() => undefined), + + models: () => invoke((client, options) => client.model.list(undefined, options)).then((out) => out.data), + + defaultModel: () => + invoke((client, options) => client.model.default(undefined, options)).then((out) => out.data), + + + switchModel: (sessionID, model) => + invoke((client, options) => client.session.switchModel({ sessionID, model }, options)).then(() => undefined), + + vcs: (directory) => + invoke((client, options) => client.vcs.get({ location: { directory } }, options)) + .then((out) => (out.data.provider ? out.data : null)) + .catch((error) => { + // A directory without version control is a normal state, not a failure. + const mapped = mapOpenCodeError(error, undefined); + if (mapped.kind === "not-found") return null; + throw error; + }), + + messages: (sessionID) => invoke((client, options) => client.session.context({ sessionID }, options)), + + pendingPermissions: async () => { + const out = await invoke((client, options) => client.permission.request.list(undefined, options)); + return new Set(out.data.map((request) => request.sessionID)); + }, + + pendingForms: async () => { + const out = await invoke((client, options) => client.form.list(undefined, options)); + return new Set(out.data.map((form) => form.sessionID)); + }, + + events: (signal) => + OpenCode.make({ + baseUrl: endpoint.url, + ...(endpoint.headers ? { headers: endpoint.headers } : {}), + }).event.subscribe({ ...(signal ? { signal } : {}) }), + }; +} diff --git a/extensions/opencode/src/lib/opencode/discovery.ts b/extensions/opencode/src/lib/opencode/discovery.ts new file mode 100644 index 000000000..8f52a7433 --- /dev/null +++ b/extensions/opencode/src/lib/opencode/discovery.ts @@ -0,0 +1,80 @@ +import { Service } from "@opencode/client/service"; +import { OpenCodeError } from "./errors"; + +/** Connection details for an OpenCode V2 server, ready to be used by the client. */ +export interface Endpoint { + readonly url: string; + readonly headers?: Record; +} + +/** User-provided connection settings. Empty means local-only auto-discovery. */ +export interface ConnectionConfig { + readonly serverUrl?: string; + readonly serverUsername?: string; + readonly serverPassword?: string; +} + +const HTTP_URL_PATTERN = /^https?:\/\//i; + +/** Validate a configured server URL. Returns the normalized URL or null when invalid. */ +export function parseServerUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed || !HTTP_URL_PATTERN.test(trimmed)) return null; + try { + const url = new URL(trimmed); + if (!url.hostname || url.username || url.password) return null; + // Strip any path/query so the endpoint is a bare origin as the client expects. + return `${url.protocol}//${url.host}`; + } catch { + return null; + } +} + +function basicAuthHeaders(username?: string, password?: string): Record | undefined { + if (!password) return undefined; + const user = username?.trim() || "opencode"; + const credentials = Buffer.from(`${user}:${password}`).toString("base64"); + return { authorization: `Basic ${credentials}` }; +} + +/** + * Resolve the OpenCode endpoint to talk to. + * + * 1. An explicitly configured server URL (validated, with optional basic auth). + * 2. The local OpenCode service through the official client discovery. + * + * Returns null when no local service is running. Never scans ports and never + * spawns servers. See {@link startOpenCodeService} for the supported start mechanism. + */ +export async function resolveEndpoint( + config: ConnectionConfig, + discovery?: { readonly file?: string }, +): Promise { + if (config.serverUrl) { + const url = parseServerUrl(config.serverUrl); + if (!url) { + throw new OpenCodeError( + "rejected", + "The configured OpenCode server URL is invalid.", + `serverUrl: ${config.serverUrl}`, + ); + } + const headers = basicAuthHeaders(config.serverUsername, config.serverPassword); + return { url, ...(headers ? { headers } : {}) }; + } + + const found = await Service.discover(discovery); + if (!found) return null; + const headers = Service.headers({ url: found.url, ...(found.auth ? { auth: found.auth } : {}) }); + return { url: found.url, ...(headers ? { headers: { ...headers } } : {}) }; +} + +/** + * Start the local OpenCode service through the official client mechanism + * (`@opencode/client/service` ensure), then wait for it to become healthy. + */ +export async function startOpenCodeService(binary: string): Promise { + const endpoint = await Service.ensure({ command: [binary, "serve", "--service"] }); + const headers = Service.headers(endpoint); + return { url: endpoint.url, ...(headers ? { headers: { ...headers } } : {}) }; +} diff --git a/extensions/opencode/src/lib/opencode/errors.ts b/extensions/opencode/src/lib/opencode/errors.ts new file mode 100644 index 000000000..27ac076ae --- /dev/null +++ b/extensions/opencode/src/lib/opencode/errors.ts @@ -0,0 +1,117 @@ +import { ClientError } from "@opencode/client"; + +/** + * Normalized failures surfaced by the OpenCode service layer. + * The UI only ever renders these kinds; raw network or API errors never leak through. + */ +export type OpenCodeErrorKind = + | "unreachable" + | "auth" + | "not-found" + | "busy" + | "timeout" + | "rejected" + | "not-installed"; + +export class OpenCodeError extends Error { + readonly kind: OpenCodeErrorKind; + /** Debug-only context. Never rendered to normal users. */ + readonly detail?: string; + + constructor(kind: OpenCodeErrorKind, message: string, detail?: string) { + super(message); + this.name = "OpenCodeError"; + this.kind = kind; + if (detail) this.detail = detail; + } +} + +export function isOpenCodeError(error: unknown): error is OpenCodeError { + return error instanceof OpenCodeError; +} + +/** The state used when auto-discovery finds no local OpenCode service. */ +export function notRunningError(): OpenCodeError { + return new OpenCodeError("unreachable", "OpenCode is not running."); +} + +const NOT_FOUND_PATTERN = /not found/i; +const BUSY_PATTERN = /\bbusy\b/i; + +function describeCause(error: unknown, depth = 0): string | undefined { + if (depth > 4 || !error || typeof error !== "object") return undefined; + const cause = (error as { cause?: unknown }).cause; + if (cause instanceof Error) { + return [cause.message, describeCause(cause.cause, depth + 1)].filter(Boolean).join(": "); + } + return undefined; +} + +/** Classify a raw message into the error kinds the UI understands. */ +function fromMessage(message: string, httpStatus?: number): OpenCodeError { + if (httpStatus === 401 || httpStatus === 403 || /unauthorized/i.test(message)) { + return new OpenCodeError("auth", "Authentication failed.", message); + } + if (NOT_FOUND_PATTERN.test(message)) { + return new OpenCodeError("not-found", "Session no longer exists.", message); + } + if (BUSY_PATTERN.test(message)) { + return new OpenCodeError("busy", "OpenCode is already working on this session.", message); + } + return new OpenCodeError("rejected", "OpenCode rejected the request.", message); +} + +/** + * Normalize any thrown error into an {@link OpenCodeError}. + * `httpStatus` comes from the adapter's fetch wrapper when the response status was observed. + */ +export function mapOpenCodeError(error: unknown, httpStatus?: number): OpenCodeError { + if (error instanceof OpenCodeError) return error; + + if (error instanceof ClientError) { + if (error.reason === "Transport") { + const detail = describeCause(error); + return new OpenCodeError("unreachable", "OpenCode server is unreachable.", detail); + } + return fromMessage(error.message, httpStatus); + } + + if (error instanceof Error) { + if (error.name === "TimeoutError" || error.name === "AbortError") { + return new OpenCodeError("timeout", "Request timed out."); + } + return fromMessage(error.message, httpStatus); + } + + // The promise client surfaces typed API errors (e.g. SessionNotFoundError) + // as plain objects carrying the API message, not as Error instances. + if (typeof error === "object") { + const rawMessage = (error as { message?: unknown }).message; + if (typeof rawMessage === "string" && rawMessage) { + return fromMessage(rawMessage, httpStatus); + } + return new OpenCodeError("rejected", "OpenCode rejected the request.", JSON.stringify(error)); + } + + return new OpenCodeError("rejected", "OpenCode rejected the request."); +} + +/** Short, user-actionable hint shown under the error title. */ +export function errorHint(error: OpenCodeError): string { + switch (error.kind) { + case "unreachable": + return "Start OpenCode and try again."; + case "auth": + return "Check the Server URL, username, and password in the extension settings."; + case "not-found": + return "It no longer exists on the OpenCode server."; + case "busy": + return "OpenCode is already working on this session. Try again in a moment."; + case "timeout": + return "OpenCode took too long to respond. Try again."; + case "rejected": + return "OpenCode could not process this request."; + case "not-installed": + return "OpenCode must be installed separately. Get the V2 CLI from https://opencode.ai/v2."; + } +} diff --git a/extensions/opencode/src/lib/opencode/types.ts b/extensions/opencode/src/lib/opencode/types.ts new file mode 100644 index 000000000..41577238f --- /dev/null +++ b/extensions/opencode/src/lib/opencode/types.ts @@ -0,0 +1,28 @@ +/** + * Types used across the extension. + * + * Everything here is derived from the installed `@opencode/client` (the official + * OpenCode V2 client). No OpenCode types come from this extension. + */ +export type { + ServerInfo, + SessionInfo, + SessionsResponse, + SessionActive, + SessionStatus, + SessionMessageInfo, + SessionMessageUser, + SessionMessageAssistant, + SessionMessageAssistantText, + Project, + ModelInfo, + ModelRef, + ModelDefaultOutput, + VcsInfo, + VcsGetOutput, + PermissionRequest, + V2Event, +} from "@opencode/client"; + +export type { OpenCodeClient } from "@opencode/client"; +export { ClientError } from "@opencode/client"; diff --git a/extensions/opencode/src/lib/project-activity.ts b/extensions/opencode/src/lib/project-activity.ts new file mode 100644 index 000000000..e3433f9ba --- /dev/null +++ b/extensions/opencode/src/lib/project-activity.ts @@ -0,0 +1,20 @@ +import type { SessionInfo } from "./opencode/types"; + +export interface ProjectActivity { + readonly last: number; + readonly count: number; +} + +/** Most recent top-level session activity and session count per project ID. */ +export function activityByProject(sessions: readonly SessionInfo[]): Map { + const byProject = new Map(); + for (const session of sessions) { + if (session.parentID) continue; + const existing = byProject.get(session.projectID); + byProject.set(session.projectID, { + last: Math.max(existing?.last ?? 0, session.time.updated), + count: (existing?.count ?? 0) + 1, + }); + } + return byProject; +} diff --git a/extensions/opencode/src/lib/prompts.ts b/extensions/opencode/src/lib/prompts.ts new file mode 100644 index 000000000..474656e25 --- /dev/null +++ b/extensions/opencode/src/lib/prompts.ts @@ -0,0 +1,46 @@ +/** Reusable prompt builders. They route work to OpenCode. They do not add AI logic. */ + +export type ReviewScope = "working" | "staged" | "branch"; + +export interface BranchInfo { + readonly current?: string; + readonly default?: string; +} + +function branchTarget(branch?: BranchInfo): { target: string; relativeTo: string } { + const current = branch?.current; + const base = branch?.default; + return { + target: current ? `branch ${current}` : "the current branch", + relativeTo: base && base !== current ? ` relative to ${base}` : "", + }; +} + +function scopeNoun(scope: ReviewScope, branch?: BranchInfo): string { + if (scope === "working") return "the current working tree changes"; + if (scope === "staged") return "the currently staged changes (git diff --cached)"; + const { target, relativeTo } = branchTarget(branch); + return `the committed changes on ${target}${relativeTo}`; +} + +function withFileFocus(prompt: string, file?: string): string { + return file ? `${prompt}\nFocus on the file \`${file}\`.` : prompt; +} + +/** Build the repository review prompt. OpenCode inspects the repository itself. */ +export function buildReviewPrompt(scope: ReviewScope, branch?: BranchInfo, file?: string): string { + const guard = "Do not modify files."; + return withFileFocus( + `Review ${scopeNoun(scope, branch)}.\nIdentify correctness issues, regressions, security problems, and suspicious changes. ${guard}`, + file, + ); +} + +/** Build the explain-changes prompt for a scope, optionally focused on one file. */ +export function buildExplainPrompt(scope: ReviewScope, branch?: BranchInfo, file?: string): string { + const guard = "Do not modify files."; + return withFileFocus( + `Explain ${scopeNoun(scope, branch)}.\nDescribe what changed and why it might matter. ${guard}`, + file, + ); +} diff --git a/extensions/opencode/src/lib/session-status.ts b/extensions/opencode/src/lib/session-status.ts new file mode 100644 index 000000000..98373b02e --- /dev/null +++ b/extensions/opencode/src/lib/session-status.ts @@ -0,0 +1,38 @@ +import type { SessionActive, SessionInfo } from "./opencode/types"; + +/** Lightweight session status derived from official OpenCode status data only. */ +export type SessionStatusKind = "working" | "waiting" | "idle" | "failed"; + +export interface StatusInputs { + /** Running sessions as reported by `GET /api/session/active`. */ + readonly active: Record; + /** Session IDs blocked on user input: pending permission requests or forms. */ + readonly waiting: ReadonlySet; +} + +/** + * Resolve the display status of a session. + * - Waiting: the session is blocked on user input (permission or question). + * - Working: OpenCode reports the session as actively running. + * - Failed: OpenCode reports a failed outcome for the latest run. + * - Idle: everything else. + */ +export function sessionStatus(session: SessionInfo, inputs: StatusInputs): SessionStatusKind { + if (inputs.waiting.has(session.id)) return "waiting"; + if (inputs.active[session.id]) return "working"; + if (session.outcome === "failed") return "failed"; + return "idle"; +} + +export function statusLabel(kind: SessionStatusKind): string { + switch (kind) { + case "working": + return "Working"; + case "waiting": + return "Waiting for Input"; + case "idle": + return "Idle"; + case "failed": + return "Failed"; + } +} diff --git a/extensions/opencode/src/lib/time.ts b/extensions/opencode/src/lib/time.ts new file mode 100644 index 000000000..e95ee9525 --- /dev/null +++ b/extensions/opencode/src/lib/time.ts @@ -0,0 +1,15 @@ +/** Format a server timestamp (milliseconds) as a short relative time. */ +export function relativeTime(timestamp: number | undefined | null): string | undefined { + if (timestamp == null) return undefined; + const diff = Date.now() - timestamp; + if (diff < 0) return "just now"; + const seconds = Math.floor(diff / 1000); + if (seconds < 45) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} diff --git a/extensions/opencode/src/new-session.tsx b/extensions/opencode/src/new-session.tsx new file mode 100644 index 000000000..a253cb75a --- /dev/null +++ b/extensions/opencode/src/new-session.tsx @@ -0,0 +1,143 @@ +import { Action, ActionPanel, Form, useNavigation } from "@vicinae/api"; +import { useMemo, useState, type ReactNode } from "react"; +import type { Config } from "./lib/config"; +import { useOpenCodeCommand } from "./components/command-gate"; +import { createOpenCodeService } from "./lib/opencode/client"; +import type { Endpoint } from "./lib/opencode/discovery"; +import type { ModelRef, Project, SessionInfo } from "./lib/opencode/types"; +import { modelValue, parseModelRef, useModels } from "./lib/models"; +import { showActionError, SentView } from "./components/actions"; +import { ModelPickerList } from "./components/model-picker"; +import { modelLabel } from "./components/model-dropdown"; +import { ProjectPickerList, basename } from "./components/project-picker"; + +/** + * New Session flow: pick a project, optionally enter a prompt, create the + * session through the OpenCode V2 API, then open it in the TUI. + */ +export function NewSessionFlow(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly initialProject?: Project; +}): ReactNode { + const { push } = useNavigation(); + if (props.initialProject) { + return ( + + ); + } + return ( + + push() + } + navigationTitle="New Session" + /> + ); +} + +function NewSessionPromptStep(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly project: Project; +}): ReactNode { + const { push } = useNavigation(); + const [model, setModel] = useState(undefined); + const [busy, setBusy] = useState(false); + const [created, setCreated] = useState(undefined); + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + const { models, defaultModelID } = useModels(props.endpoint); + + // The model that will be used: explicitly chosen, or OpenCode's default. + const activeModelId = model ? modelValue(model) : defaultModelID; + const knownActiveModelId = + activeModelId && models.some((candidate) => modelValue(candidate) === activeModelId) + ? activeModelId + : undefined; + + const handleSubmit = async (values: { readonly prompt?: string }) => { + if (busy) return; + const text = (values.prompt ?? "").trim(); + setBusy(true); + try { + const session = await service.createSession({ + directory: props.project.canonical, + ...(model ? { model } : {}), + }); + if (text) await service.sendPrompt(session.id, text); + setCreated(session); + } catch (error) { + await showActionError(error, "Failed to create session"); + } finally { + setBusy(false); + } + }; + + if (created) { + return ( + setCreated(undefined)} + /> + ); + } + + return ( +
+ + push()} + /> + + } + > + + + {models.length > 0 && knownActiveModelId ? ( + setModel(parseModelRef(value))} + filtering + > + {models.map((candidate) => { + const value = modelValue(candidate); + return ( + + ); + })} + + ) : activeModelId ? ( + + ) : null} + + ); +} + +export default function NewSessionCommand(): ReactNode { + const command = useOpenCodeCommand(); + if (!command.ready) return command.view; + return ; +} diff --git a/extensions/opencode/src/projects.tsx b/extensions/opencode/src/projects.tsx new file mode 100644 index 000000000..fbaa38cfe --- /dev/null +++ b/extensions/opencode/src/projects.tsx @@ -0,0 +1,179 @@ +import { Action, ActionPanel, Icon, List, useNavigation } from "@vicinae/api"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import type { Config } from "./lib/config"; +import { createOpenCodeService } from "./lib/opencode/client"; +import { mapOpenCodeError, type OpenCodeError } from "./lib/opencode/errors"; +import type { Endpoint } from "./lib/opencode/discovery"; +import type { Project, SessionActive, SessionInfo } from "./lib/opencode/types"; +import { activityByProject } from "./lib/project-activity"; +import { relativeTime } from "./lib/time"; +import { OpenDirectoryAction, OpenTerminalAction } from "./components/actions"; +import { basename } from "./components/project-picker"; +import { useOpenCodeCommand } from "./components/command-gate"; +import { NewSessionFlow } from "./new-session"; +import { SessionListView } from "./components/session-list"; +import { OpenCodeErrorView } from "./components/error-state"; + +const GLOBAL_DIRECTORY = "/"; + +/** + * Project browser backed by the official project API. Projects are sorted by + * their most recent session activity; the global pseudo-project is hidden. + */ +export function ProjectListView(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly navigationTitle: string; +}): ReactNode { + const { push } = useNavigation(); + const [projects, setProjects] = useState([]); + const [sessions, setSessions] = useState([]); + const [active, setActive] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + + const load = useCallback( + async (signal?: AbortSignal) => { + setIsLoading(true); + try { + const [projectList, page, activeMap] = await Promise.all([ + service.projects(), + service.sessions({ limit: 100 }).catch(() => null), + service.activeSessions().catch(() => ({})), + ]); + if (signal?.aborted) return; + setProjects(projectList); + if (page) setSessions(page.data); + setActive(activeMap); + setError(null); + } catch (caught) { + if (signal?.aborted) return; + setError(mapOpenCodeError(caught)); + } finally { + if (!signal?.aborted) setIsLoading(false); + } + }, + [service], + ); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load, reloadKey]); + + const visibleProjects = useMemo(() => { + const activity = activityByProject(sessions); + return projects + .filter((project) => project.canonical !== GLOBAL_DIRECTORY) + .map((project) => ({ + project, + lastActivity: activity.get(project.id)?.last, + sessionCount: activity.get(project.id)?.count ?? 0, + })) + .sort((a, b) => (b.lastActivity ?? b.project.time?.updated ?? 0) - (a.lastActivity ?? a.project.time?.updated ?? 0)); + }, [projects, sessions]); + + if (error) { + return setReloadKey((v) => v + 1)} />; + } + + return ( + + {visibleProjects.length === 0 && !isLoading ? ( + + push()} + /> + + } + /> + ) : ( + visibleProjects.map(({ project, lastActivity, sessionCount }) => { + const working = sessions.some( + (session) => session.projectID === project.id && !session.parentID && active[session.id], + ); + return ( + 0 ? `${sessionCount} session${sessionCount === 1 ? "" : "s"}` : "" }, + { text: relativeTime(lastActivity ?? project.time?.updated) }, + ]} + detail={ + + } + actions={ + + + push( + , + ) + } + /> + + push( + , + ) + } + /> + + + + } + /> + ); + }) + )} + + ); +} + +export default function ProjectsCommand(): ReactNode { + const command = useOpenCodeCommand(); + if (!command.ready) return command.view; + return ( + + ); +} diff --git a/extensions/opencode/src/review-changes.tsx b/extensions/opencode/src/review-changes.tsx new file mode 100644 index 000000000..5d24b05c9 --- /dev/null +++ b/extensions/opencode/src/review-changes.tsx @@ -0,0 +1,436 @@ +import { Action, ActionPanel, Detail, Icon, List, Toast, showToast, useNavigation } from "@vicinae/api"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { join } from "node:path"; +import type { Config } from "./lib/config"; +import { useOpenCodeCommand } from "./components/command-gate"; +import { createOpenCodeService, type OpenCodeService } from "./lib/opencode/client"; +import type { Endpoint } from "./lib/opencode/discovery"; +import type { Project, VcsInfo } from "./lib/opencode/types"; +import { + checkGit, + createGitRunner, + formatChangeStat, + listChangedFiles, + loadFileDiff, + MAX_DIFF_LINES, + type ChangedFile, + type FileDiff, + type GitRunner, + type ReviewScope, +} from "./lib/git"; +import { buildExplainPrompt, buildReviewPrompt } from "./lib/prompts"; +import { ProjectPickerList, basename } from "./components/project-picker"; +import { OpenTUIAction, OpenTerminalAction, SentView } from "./components/actions"; + +const SCOPE_LABEL: Record = { + working: "Working Tree", + staged: "Staged", + branch: "Branch", +}; + +const EMPTY_MESSAGE: Record = { + working: "The working tree is clean.", + staged: "Nothing is staged.", + branch: "No commits ahead of the base branch.", +}; + +async function requestReview( + service: OpenCodeService, + directory: string, + prompt: string, +): Promise { + const session = await service.createSession({ directory }); + await service.sendPrompt(session.id, prompt); + return session.id; +} + +function useReviewSender(props: { + readonly endpoint: Endpoint; + readonly directory: string; +}): { + readonly busy: boolean; + readonly sent: string | undefined; + readonly send: (prompt: string) => void; + readonly reset: () => void; +} { + const [busy, setBusy] = useState(false); + const [sent, setSent] = useState(undefined); + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + + const send = useCallback( + (prompt: string) => { + if (busy) return; + setBusy(true); + void (async () => { + try { + setSent(await requestReview(service, props.directory, prompt)); + } catch (error) { + setSent(undefined); + await showToast({ + style: Toast.Style.Failure, + title: "Failed to request review", + message: error instanceof Error ? error.message : "Failed to request review.", + }); + } finally { + setBusy(false); + } + })(); + }, + [busy, service, props.directory], + ); + + return { + busy, + sent, + send, + reset: useCallback(() => setSent(undefined), []), + }; +} + +function FileDiffView(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly project: Project; + readonly scope: ReviewScope; + readonly file: ChangedFile; + readonly branch: VcsInfo["branch"] | undefined; +}): ReactNode { + const runner = useMemo(() => createGitRunner(props.project.canonical), [props.project.canonical]); + const [diff, setDiff] = useState(undefined); + const [failed, setFailed] = useState(false); + const sender = useReviewSender({ endpoint: props.endpoint, directory: props.project.canonical }); + + useEffect(() => { + let cancelled = false; + void loadFileDiff(runner, props.scope, props.file, props.branch?.default) + .then((loaded) => { + if (!cancelled) setDiff(loaded); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { + cancelled = true; + }; + }, [runner, props.scope, props.file, props.branch]); + + if (sender.sent) { + return ( + + ); + } + + const markdown = + diff === undefined && !failed + ? "Loading diff…" + : [ + `## \`${props.file.path}\``, + "", + diff && diff.diff + ? ["```diff", diff.diff, "```"].join("\n") + : "_No textual diff for this file._", + ...(diff?.truncated + ? ["", `_Showing the first ${MAX_DIFF_LINES} lines. Open the terminal for the full diff._`] + : []), + ].join("\n"); + + return ( + + + + + + } + actions={ + + + sender.send(buildReviewPrompt(props.scope, props.branch, props.file.path)) + } + /> + + sender.send(buildExplainPrompt(props.scope, props.branch, props.file.path)) + } + /> + {diff && diff.diff ? ( + + ) : null} + + + } + /> + ); +} + +/** + * Review: read the actual repository diff in Vicinae first, then + * route review work to OpenCode. The diff is rendered per file with syntax + * highlighting. OpenCode does the reviewing. This extension only shows the result. + */ +function ReviewScopeStep(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly project: Project; +}): ReactNode { + const { pop, push } = useNavigation(); + const [vcs, setVcs] = useState(undefined); + const [gitOK, setGitOK] = useState(undefined); + const [scope, setScope] = useState("working"); + const [files, setFiles] = useState([]); + const [filesLoading, setFilesLoading] = useState(true); + const [filesFailed, setFilesFailed] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + const runner = useMemo( + () => createGitRunner(props.project.canonical), + [props.project.canonical], + ); + const sender = useReviewSender({ endpoint: props.endpoint, directory: props.project.canonical }); + + useEffect(() => { + let cancelled = false; + void service + .vcs(props.project.canonical) + .then((info) => { + if (!cancelled) setVcs(info); + }) + .catch(() => { + if (!cancelled) setVcs(null); + }); + return () => { + cancelled = true; + }; + }, [props.project.canonical, service]); + + useEffect(() => { + let cancelled = false; + void checkGit(runner).then((ok) => { + if (!cancelled) setGitOK(ok); + }); + return () => { + cancelled = true; + }; + }, [runner]); + + const base = vcs?.branch.default; + const branchAvailable = base !== undefined; + const projectTitle = `OpenCode Review · ${basename(props.project.canonical)}`; + + useEffect(() => { + if (gitOK !== true) return; + const activeScope: ReviewScope = scope === "branch" && !branchAvailable ? "working" : scope; + if (activeScope !== scope) { + setScope(activeScope); + return; + } + let cancelled = false; + setFilesLoading(true); + setFilesFailed(false); + void listChangedFiles(runner, activeScope, base) + .then((loaded) => { + if (cancelled) return; + setFiles(loaded); + setFilesLoading(false); + }) + .catch(() => { + if (cancelled) return; + setFilesFailed(true); + setFilesLoading(false); + }); + return () => { + cancelled = true; + }; + }, [runner, scope, base, branchAvailable, gitOK, reloadKey]); + + if (sender.sent) { + return ( + + ); + } + + if (vcs === null) { + return ( + + + + + } + /> + + ); + } + + if (gitOK === false) { + return ( + + + + + } + /> + + ); + } + + const branch = vcs?.branch; + const showEmpty = !filesLoading && !filesFailed && files.length === 0; + + return ( + setScope(value as ReviewScope)}> + + + {branchAvailable ? : null} + + } + actions={ + + sender.send(buildReviewPrompt(scope, branch))} + /> + sender.send(buildExplainPrompt(scope, branch))} + /> + + + } + > + {showEmpty ? ( + + ) : filesFailed ? ( + + setReloadKey((value) => value + 1)} + /> + + } + /> + ) : ( + files.map((file) => ( + + + push( + , + ) + } + /> + sender.send(buildReviewPrompt(scope, branch, file.path))} + /> + + + } + /> + )) + )} + + ); +} + +export default function ReviewChangesCommand(): ReactNode { + const command = useOpenCodeCommand(); + const { push } = useNavigation(); + + if (!command.ready) return command.view; + const endpoint = command.endpoint; + return ( + push()} + navigationTitle="OpenCode Review" + /> + ); +} diff --git a/extensions/opencode/src/sessions.tsx b/extensions/opencode/src/sessions.tsx new file mode 100644 index 000000000..3cb139cb4 --- /dev/null +++ b/extensions/opencode/src/sessions.tsx @@ -0,0 +1,11 @@ +import { type ReactNode } from "react"; +import { useOpenCodeCommand } from "./components/command-gate"; +import { SessionListView } from "./components/session-list"; + +export default function SessionsCommand(): ReactNode { + const command = useOpenCodeCommand(); + if (!command.ready) return command.view; + return ( + + ); +} diff --git a/extensions/opencode/test/actions.test.ts b/extensions/opencode/test/actions.test.ts new file mode 100644 index 000000000..0ef8e6f3d --- /dev/null +++ b/extensions/opencode/test/actions.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { buildReviewPrompt } from "../src/lib/prompts"; +import { jsonResponse, makeService, sessionFixture } from "./helpers"; + +describe("flows", () => { + test("quick ask creates a session in the project and sends the prompt", async () => { + const { service, requests } = makeService(({ url, method }) => { + if (url.endsWith("/api/session") && method === "POST") { + return jsonResponse({ data: sessionFixture({ id: "ses_quick", title: "Quick" }) }); + } + if (url.includes("/prompt") && method === "POST") { + return jsonResponse({ data: { id: "msg_1", text: "hi", type: "user", time: { created: 1 } } }); + } + return jsonResponse({}, 404); + }); + const session = await service.createSession({ directory: "/tmp/project" }); + await service.sendPrompt(session.id, "Explain this error and suggest a fix."); + + const createRequest = requests.find((request) => request.method === "POST" && request.url.endsWith("/api/session")); + const promptRequest = requests.find((request) => request.url.includes("/prompt")); + const createBody = JSON.parse(createRequest?.body ?? "{}") as { location?: { directory?: string } }; + expect(createBody.location?.directory).toBe("/tmp/project"); + const promptBody = JSON.parse(promptRequest?.body ?? "{}") as { text?: string }; + expect(promptBody.text).toBe("Explain this error and suggest a fix."); + }); + + test("resume prerequisite: a session can be fetched before opening", async () => { + const { service } = makeService(({ url }) => + url.includes("/api/session/ses_test123") + ? jsonResponse({ data: sessionFixture() }) + : jsonResponse({}, 404), + ); + const session = await service.session("ses_test123"); + expect(session.id).toBe("ses_test123"); + expect(session.location?.directory).toBe("/tmp/project"); + }); + + test("review changes sends a review prompt into the project", async () => { + const { service, requests } = makeService(({ url, method }) => { + if (url.endsWith("/api/session") && method === "POST") { + return jsonResponse({ data: sessionFixture({ id: "ses_review" }) }); + } + if (url.includes("/prompt") && method === "POST") { + return jsonResponse({ data: { id: "msg_1", text: "", type: "user", time: { created: 1 } } }); + } + if (url.includes("/api/vcs")) { + return jsonResponse({ + location: { directory: "/tmp/project" }, + data: { provider: "git", branch: { current: "main", default: "main" } }, + }); + } + return jsonResponse({}, 404); + }); + + const vcs = await service.vcs("/tmp/project"); + expect(vcs?.branch?.current).toBe("main"); + + const session = await service.createSession({ directory: "/tmp/project" }); + await service.sendPrompt(session.id, buildReviewPrompt("working", vcs?.branch)); + + const promptBody = JSON.parse( + requests.find((request) => request.url.includes("/prompt"))?.body ?? "{}", + ) as { text?: string }; + expect(promptBody.text).toContain("working tree changes"); + expect(promptBody.text).toContain("Do not modify files."); + }); + + test("a directory without version control resolves to null", async () => { + const { service } = makeService(({ url }) => + url.includes("/api/vcs") + ? jsonResponse({ location: { directory: "/tmp" }, data: { branch: {} } }) + : jsonResponse({}, 404), + ); + const vcs = await service.vcs("/tmp"); + expect(vcs).toBeNull(); + }); + + test("deleting a missing session surfaces a not-found error", async () => { + const { service } = makeService(({ method }) => + method === "DELETE" + ? jsonResponse({ _tag: "SessionNotFoundError", sessionID: "ses_gone", message: "Session not found: ses_gone" }, 404) + : jsonResponse({}, 404), + ); + try { + await service.deleteSession("ses_gone"); + throw new Error("expected deleteSession to reject"); + } catch (error) { + expect((error as Error).message).toBe("Session no longer exists."); + } + }); +}); diff --git a/extensions/opencode/test/config.test.ts b/extensions/opencode/test/config.test.ts new file mode 100644 index 000000000..76e1b7699 --- /dev/null +++ b/extensions/opencode/test/config.test.ts @@ -0,0 +1,87 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { installVicinaeStubs } from "./setup"; + +const stubs = installVicinaeStubs(); + +const { loadConfig } = await import("../src/lib/config"); +const { resolveOpenCodeBinary, openTerminalAt, openSessionInTUI, openTUI } = await import("../src/lib/launch"); + +const tempDir = mkdtempSync(join(tmpdir(), "opencode-config-")); +afterEach(() => { + stubs.preferences = {}; +}); +afterAll(() => rmSync(tempDir, { recursive: true, force: true })); + +describe("config", () => { + test("defaults are auto-detected and nothing is required", () => { + const config = loadConfig(); + expect(config.serverUrl).toBeUndefined(); + expect(config.serverUsername).toBe("opencode"); + expect(config.serverPassword).toBeUndefined(); + expect(config.openCodePath).toBeUndefined(); + expect(config.notifyOnCompletion).toBe(false); + }); + + test("configured values are normalized", () => { + stubs.preferences = { + serverUrl: " http://127.0.0.1:49374 ", + serverUsername: " alice ", + serverPassword: " secret ", + openCodePath: " /usr/local/bin/opencode ", + notifyOnCompletion: true, + }; + const config = loadConfig(); + expect(config.serverUrl).toBe("http://127.0.0.1:49374"); + expect(config.serverUsername).toBe("alice"); + expect(config.serverPassword).toBe("secret"); + expect(config.openCodePath).toBe("/usr/local/bin/opencode"); + expect(config.notifyOnCompletion).toBe(true); + }); +}); + +describe("launch", () => { + test("a configured V2 binary is verified and used", async () => { + const fake = join(tempDir, "fake-opencode-v2"); + writeFileSync(fake, "#!/bin/sh\necho 'opencode v2.0.8'\n"); + chmodSync(fake, 0o755); + const binary = await resolveOpenCodeBinary(fake); + expect(binary).toBe(fake); + }); + + test("a non-V2 configured binary is rejected", async () => { + const fake = join(tempDir, "fake-opencode-v1"); + writeFileSync(fake, "#!/bin/sh\necho '1.18.31'\n"); + chmodSync(fake, 0o755); + try { + await resolveOpenCodeBinary(fake); + throw new Error("expected resolveOpenCodeBinary to reject"); + } catch (error) { + expect((error as Error).message).toBe("The configured OpenCode executable is not OpenCode V2."); + } + }); + + test("a terminal opens at the directory with safe quoting", async () => { + stubs.spawnedCommands = []; + await openTerminalAt("/tmp/some dir with 'quotes'"); + expect(stubs.spawnedCommands).toHaveLength(1); + const command = stubs.spawnedCommands[0]?.join(" ") ?? ""; + expect(command).toContain("cd -- "); + expect(command).toContain("'\"'\"'"); // the quote is escaped, not injected + }); + + test("resume opens the TUI at the session", async () => { + stubs.spawnedCommands = []; + await openSessionInTUI("/usr/bin/opencode", "ses_abc", "/tmp/project"); + expect(stubs.spawnedCommands[0]).toEqual(["/usr/bin/opencode", "--session", "ses_abc", "/tmp/project"]); + }); + + test("open TUI works without a directory", async () => { + stubs.spawnedCommands = []; + await openTUI("/usr/bin/opencode"); + expect(stubs.spawnedCommands[0]).toEqual(["/usr/bin/opencode"]); + }); +}); + diff --git a/extensions/opencode/test/conversation-detail.test.ts b/extensions/opencode/test/conversation-detail.test.ts new file mode 100644 index 000000000..ba2faead0 --- /dev/null +++ b/extensions/opencode/test/conversation-detail.test.ts @@ -0,0 +1,61 @@ +import type { ConversationMessage } from "../src/components/conversation-detail"; +import { describe, expect, test } from "bun:test"; +import { installVicinaeStubs } from "./setup"; + +installVicinaeStubs(); + +const { conversationMarkdown } = await import("../src/components/conversation-detail"); + +function message(overrides: Partial & { id: string }): ConversationMessage { + return { role: "user", text: "hello", time: 1, ...overrides }; +} + +describe("conversation markdown", () => { + test("renders in natural reading order: oldest at top, newest at bottom", () => { + const markdown = conversationMarkdown( + [ + message({ id: "1", role: "user", text: "first question" }), + message({ id: "2", role: "assistant", text: "first answer" }), + message({ id: "3", role: "user", text: "follow-up" }), + message({ id: "4", role: "assistant", text: "second answer" }), + ], + false, + ); + const firstQuestion = markdown.indexOf("first question"); + const firstAnswer = markdown.indexOf("first answer"); + const followUp = markdown.indexOf("follow-up"); + const secondAnswer = markdown.indexOf("second answer"); + expect(firstQuestion).toBeLessThan(firstAnswer); + expect(firstAnswer).toBeLessThan(followUp); + expect(followUp).toBeLessThan(secondAnswer); + }); + + test("marks authors and separates turns", () => { + const markdown = conversationMarkdown( + [ + message({ id: "1", role: "user", text: "q" }), + message({ id: "2", role: "assistant", text: "a" }), + ], + false, + ); + expect(markdown).toContain("**You**"); + expect(markdown).toContain("**Assistant**"); + expect(markdown).toContain("---"); + }); + + test("streaming messages show an ellipsis marker", () => { + const markdown = conversationMarkdown( + [ + message({ id: "1", role: "user", text: "q" }), + message({ id: "2", role: "assistant", text: "", streaming: true }), + ], + true, + ); + expect(markdown).toContain("⋯"); + }); + + test("errors render as a plain block", () => { + const markdown = conversationMarkdown([message({ id: "1", role: "error", text: "OpenCode is not running." })], false); + expect(markdown).toContain("OpenCode is not running."); + }); +}); diff --git a/extensions/opencode/test/discovery.test.ts b/extensions/opencode/test/discovery.test.ts new file mode 100644 index 000000000..ba2175faf --- /dev/null +++ b/extensions/opencode/test/discovery.test.ts @@ -0,0 +1,113 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseServerUrl, resolveEndpoint } from "../src/lib/opencode/discovery"; +import { fakeFetch, jsonResponse, serverInfoFixture } from "./helpers"; + +const tempDir = mkdtempSync(join(tmpdir(), "opencode-discovery-")); +afterAll(() => rmSync(tempDir, { recursive: true, force: true })); + +function registrationFile(overrides: Record = {}): string { + const file = join(tempDir, `service-${Math.random().toString(36).slice(2)}.json`); + writeFileSync( + file, + JSON.stringify({ + id: "test-service", + version: "2.0.8", + url: "http://127.0.0.1:45999", + pid: 1234, + password: "secret-password", + ...overrides, + }), + ); + return file; +} + +describe("server URL validation", () => { + test("accepts http and https URLs and strips paths", () => { + expect(parseServerUrl("http://127.0.0.1:49374")).toBe("http://127.0.0.1:49374"); + expect(parseServerUrl("https://opencode.example.com/some/path?x=1")).toBe("https://opencode.example.com"); + expect(parseServerUrl(" http://localhost:4096 ")).toBe("http://localhost:4096"); + }); + + test("rejects non-http URLs, bare hosts, and embedded credentials", () => { + expect(parseServerUrl("not-a-url")).toBeNull(); + expect(parseServerUrl("ftp://example.com")).toBeNull(); + expect(parseServerUrl("localhost:4096")).toBeNull(); + expect(parseServerUrl("http://user:pass@example.com")).toBeNull(); + }); +}); + +describe("discovery", () => { + test("configured server is used directly, without credentials by default", async () => { + const endpoint = await resolveEndpoint({ serverUrl: "http://127.0.0.1:49374/" }); + expect(endpoint?.url).toBe("http://127.0.0.1:49374"); + expect(endpoint?.headers).toBeUndefined(); + }); + + test("configured server with a password sends basic auth", async () => { + const endpoint = await resolveEndpoint({ + serverUrl: "http://127.0.0.1:49374", + serverUsername: "alice", + serverPassword: "wonderland", + }); + expect(endpoint?.headers?.["authorization"]).toBe(`Basic ${Buffer.from("alice:wonderland").toString("base64")}`); + }); + + test("configured server without a username falls back to opencode", async () => { + const endpoint = await resolveEndpoint({ + serverUrl: "http://127.0.0.1:49374", + serverPassword: "secret", + }); + expect(endpoint?.headers?.["authorization"]).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`); + }); + + test("invalid endpoint is rejected", async () => { + try { + await resolveEndpoint({ serverUrl: "not-a-url" }); + throw new Error("expected resolveEndpoint to reject"); + } catch (error) { + expect((error as Error).message).toBe("The configured OpenCode server URL is invalid."); + } + }); + + test("detected server comes from the official discovery with auth", async () => { + // The registration file points at a fake healthy server; the health check + // is mocked so the test is hermetic. + const file = registrationFile(); + const { fetch } = fakeFetch(({ url }) => + url.endsWith("/api/info") ? jsonResponse(serverInfoFixture()) : jsonResponse({}, 404), + ); + const original = globalThis.fetch; + globalThis.fetch = fetch; + try { + const endpoint = await resolveEndpoint({}, { file }); + expect(endpoint?.url).toBe("http://127.0.0.1:45999"); + expect(endpoint?.headers?.["authorization"]).toContain("Basic "); + } finally { + globalThis.fetch = original; + } + }); + + test("unavailable server resolves to null", async () => { + // A registration whose URL nobody answers: discovery yields nothing. + const file = registrationFile({ url: "http://127.0.0.1:45998" }); + const { fetch } = fakeFetch(() => { + throw new TypeError("Unable to connect"); + }); + const original = globalThis.fetch; + globalThis.fetch = fetch; + try { + const endpoint = await resolveEndpoint({}, { file }); + expect(endpoint).toBeNull(); + } finally { + globalThis.fetch = original; + } + }); + + test("missing registration file resolves to null", async () => { + const endpoint = await resolveEndpoint({}, { file: join(tempDir, "does-not-exist.json") }); + expect(endpoint).toBeNull(); + }); +}); diff --git a/extensions/opencode/test/git.test.ts b/extensions/opencode/test/git.test.ts new file mode 100644 index 000000000..0a3dd5e54 --- /dev/null +++ b/extensions/opencode/test/git.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect } from "bun:test"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { + checkGit, + createGitRunner, + listChangedFiles, + loadFileDiff, + parseNumstat, + parsePorcelainUntracked, + MAX_DIFF_LINES, + type GitRunner, +} from "../src/lib/git"; + +function fakeRunner(responses: Record): GitRunner { + return async (args) => { + const key = args.join(" "); + for (const [prefix, output] of Object.entries(responses)) { + if (key.startsWith(prefix)) return output; + } + throw new Error(`unexpected git call: ${key}`); + }; +} + +describe("git output parsing", () => { + // `--numstat` and `--porcelain=v1 -z` are git's external contract; these pin + // that the documented formats parse into the file list the UI shows. + test("numstat parses additions, deletions, paths, and binary markers", () => { + const files = parseNumstat("12\t3\tsrc/a.ts\n-\t-\tassets/logo.png\n"); + expect(files).toEqual([ + { path: "src/a.ts", additions: 12, deletions: 3, untracked: false }, + { path: "assets/logo.png", additions: -1, deletions: -1, untracked: false }, + ]); + }); + + test("numstat rename entries carry both paths", () => { + const files = parseNumstat("5\t2\told/name.ts\tnew/name.ts\n"); + expect(files).toEqual([{ path: "old/name.ts\tnew/name.ts", additions: 5, deletions: 2, untracked: false }]); + }); + + test("porcelain -z collects untracked paths", () => { + const output = " M src/a.ts\0?? new-file.ts\0A staged.ts\0"; + expect(parsePorcelainUntracked(output)).toEqual(["new-file.ts"]); + }); + + test("empty output means no changes and nothing untracked", () => { + expect(parseNumstat("")).toEqual([]); + expect(parsePorcelainUntracked("")).toEqual([]); + }); +}); + +describe("diff truncation", () => { + test("long diffs truncate to the line cap and say so", async () => { + const big = Array.from({ length: MAX_DIFF_LINES + 50 }, (_, i) => `+line ${i}`).join("\n"); + const diff = await loadFileDiff(fakeRunner({ diff: big }), "working", { + path: "big.ts", + additions: 999, + deletions: 0, + untracked: false, + }); + expect(diff.truncated).toBe(true); + expect(diff.diff.split("\n")).toHaveLength(MAX_DIFF_LINES); + }); +}); + +describe("real git repository", () => { + function sh(cmd: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, encoding: "utf8" }, (error, stdout) => + error ? reject(error) : resolve(stdout), + ); + }); + } + + test("working-tree review sees modified and untracked files end to end", async () => { + const available = await checkGit(createGitRunner(tmpdir())); + if (!available) return; // git is required for this test; CI images may lack it. + + const dir = await mkdtemp(join(tmpdir(), "opencode-review-test-")); + try { + const git = (args: string[]) => sh("git", args, dir); + await git(["init", "-b", "main"]); + await writeFile(join(dir, "tracked.txt"), "one\n"); + await git(["add", "tracked.txt"]); + await git(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]); + await writeFile(join(dir, "tracked.txt"), "one\ntwo\n"); + await writeFile(join(dir, "new.txt"), "hello\n"); + + const runner = createGitRunner(dir); + const working = await listChangedFiles(runner, "working"); + const paths = working.map((file) => file.path).sort(); + expect(paths).toEqual(["new.txt", "tracked.txt"]); + expect(working.find((file) => file.path === "new.txt")?.untracked).toBe(true); + + const diff = await loadFileDiff( + runner, + "working", + working.find((file) => file.path === "tracked.txt")!, + ); + expect(diff.diff).toContain("+two"); + expect(diff.truncated).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/opencode/test/helpers.ts b/extensions/opencode/test/helpers.ts new file mode 100644 index 000000000..3cc776956 --- /dev/null +++ b/extensions/opencode/test/helpers.ts @@ -0,0 +1,75 @@ +import type { OpenCodeService } from "../src/lib/opencode/client"; +import { createOpenCodeService } from "../src/lib/opencode/client"; + +export interface RecordedRequest { + readonly url: string; + readonly method: string; + readonly body?: string; + readonly headers: Record; +} + +export type Responder = (request: RecordedRequest) => Response | Promise; + +export function fakeFetch(responder: Responder): { readonly fetch: typeof fetch; readonly requests: RecordedRequest[] } { + const requests: RecordedRequest[] = []; + const handler = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + // Honor an already-aborted signal the way the platform fetch does. + const signal = init?.signal; + if (signal?.aborted) { + throw new DOMException("This operation was aborted", "AbortError"); + } + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + const body = typeof init?.body === "string" ? init.body : undefined; + const headers: Record = {}; + if (init?.headers) { + new Headers(init.headers).forEach((value, key) => { + headers[key] = value; + }); + } + requests.push({ url, method, ...(body !== undefined ? { body } : {}), headers }); + return responder({ url, method, ...(body !== undefined ? { body } : {}), headers }); + }; + const fetchMock = handler as unknown as typeof fetch; + return { fetch: fetchMock, requests }; +} + +export function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); +} + +export function textResponse(text: string, status: number): Response { + return new Response(text, { status, headers: { "content-type": "text/plain" } }); +} + +export function makeService( + responder: Responder, +): { readonly service: OpenCodeService; readonly requests: RecordedRequest[] } { + const { fetch, requests } = fakeFetch(responder); + return { service: createOpenCodeService({ url: "http://127.0.0.1:45999" }, { fetch }), requests }; +} + +export const NOW = 1_700_000_000_000; + +/** A Session.Info shaped like the real V2 API response. */ +export function sessionFixture(overrides: Record = {}): Record { + return { + id: "ses_test123", + projectID: "proj_test", + cost: 0.01, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: NOW - 60_000, updated: NOW - 30_000 }, + location: { directory: "/tmp/project" }, + title: "Test session", + agent: "build", + model: { id: "gpt-4o", providerID: "openai" }, + ...overrides, + }; +} + +export function serverInfoFixture(): Record { + return { version: "2.0.8", pid: 1234, urls: ["http://127.0.0.1:45999"], paths: { tmp: "/tmp" } }; +} diff --git a/extensions/opencode/test/models.test.ts b/extensions/opencode/test/models.test.ts new file mode 100644 index 000000000..63f111b80 --- /dev/null +++ b/extensions/opencode/test/models.test.ts @@ -0,0 +1,15 @@ +import { describe, test, expect } from "bun:test"; +import { modelValue, parseModelRef } from "../src/lib/models"; + +describe("model dropdown value contract", () => { + test("dropdown values round-trip, and malformed values never produce a model ref", () => { + // The dropdown value travels to the send path and becomes a switchModel + // call, so the two directions must agree and garbage must not reach the API. + const ref = { providerID: "anthropic", id: "claude-sonnet-4" }; + expect(parseModelRef(modelValue(ref))).toEqual(ref); + + for (const malformed of ["no-slash", "/missing-provider", "missing-id/", ""]) { + expect(parseModelRef(malformed)).toBeUndefined(); + } + }); +}); diff --git a/extensions/opencode/test/opencode-client.test.ts b/extensions/opencode/test/opencode-client.test.ts new file mode 100644 index 000000000..0cd9f22aa --- /dev/null +++ b/extensions/opencode/test/opencode-client.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenCodeService } from "../src/lib/opencode/client"; +import { mapOpenCodeError, isOpenCodeError } from "../src/lib/opencode/errors"; +import { + fakeFetch, + jsonResponse, + makeService, + serverInfoFixture, + sessionFixture, + textResponse, +} from "./helpers"; + +describe("OpenCode client adapter", () => { + test("health success returns server info", async () => { + const { service, requests } = makeService(({ url }) => + url.endsWith("/api/info") ? jsonResponse(serverInfoFixture()) : jsonResponse({}, 404), + ); + const info = await service.info(); + expect(info.version).toBe("2.0.8"); + expect(requests[0]?.method).toBe("GET"); + expect(requests[0]?.url).toContain("/api/info"); + }); + + test("server unavailable maps to a reachable error", async () => { + const { service } = makeService(() => { + throw new TypeError("Unable to connect"); + }); + try { + await service.info(); + throw new Error("expected info() to reject"); + } catch (error) { + expect(isOpenCodeError(error)).toBe(true); + expect((error as Error).message).toBe("OpenCode server is unreachable."); + } + }); + + test("malformed response maps to a rejected request", async () => { + const { service } = makeService(({ url }) => + url.endsWith("/api/info") ? textResponse("not json", 200) : jsonResponse({}, 404), + ); + try { + await service.info(); + throw new Error("expected info() to reject"); + } catch (error) { + expect(isOpenCodeError(error)).toBe(true); + expect((error as Error).message).toBe("OpenCode rejected the request."); + } + }); + + test("authentication failure maps to an auth error", async () => { + // The real server answers 401 with a non-JSON body. + const { service } = makeService(({ url }) => + url.endsWith("/api/info") ? textResponse("unauthorized", 401) : jsonResponse({}, 404), + ); + try { + await service.info(); + throw new Error("expected info() to reject"); + } catch (error) { + expect(isOpenCodeError(error)).toBe(true); + expect((error as Error).message).toBe("Authentication failed."); + } + }); + + test("missing session maps to a not-found error", async () => { + const { service } = makeService(({ url, method }) => + url.includes("/api/session/ses_gone") && method === "GET" + ? jsonResponse({ _tag: "SessionNotFoundError", sessionID: "ses_gone", message: "Session not found: ses_gone" }, 404) + : jsonResponse({}, 404), + ); + try { + await service.session("ses_gone"); + throw new Error("expected session() to reject"); + } catch (error) { + expect(isOpenCodeError(error)).toBe(true); + expect((error as Error).message).toBe("Session no longer exists."); + } + }); + + test("session listing returns data and pagination cursor", async () => { + const { service, requests } = makeService(({ url }) => { + if (url.includes("/api/session?")) { + return jsonResponse({ + data: [sessionFixture(), sessionFixture({ id: "ses_test456", title: "Second" })], + cursor: { previous: null, next: "cursor-token" }, + }); + } + return jsonResponse({}, 404); + }); + const page = await service.sessions({ limit: 2 }); + expect(page.data).toHaveLength(2); + expect(page.next).toBe("cursor-token"); + expect(requests[0]?.url).toContain("limit=2"); + }); + + test("server-side search is passed through", async () => { + const { service, requests } = makeService(({ url }) => + url.includes("/api/session?") + ? jsonResponse({ data: [sessionFixture()], cursor: { previous: null, next: null } }) + : jsonResponse({}, 404), + ); + const page = await service.sessions({ search: "sheltermark", limit: 50 }); + expect(page.data).toHaveLength(1); + expect(requests[0]?.url).toContain("search=sheltermark"); + }); + + test("project filter is passed through", async () => { + const { service, requests } = makeService(({ url }) => + url.includes("/api/session?") + ? jsonResponse({ data: [], cursor: { previous: null, next: null } }) + : jsonResponse({}, 404), + ); + await service.sessions({ projectID: "proj_abc" }); + expect(requests[0]?.url).toContain("project=proj_abc"); + }); + + test("session creation sends the location and returns the session", async () => { + const { service, requests } = makeService(({ url, method }) => + url.endsWith("/api/session") && method === "POST" + ? jsonResponse({ data: sessionFixture({ id: "ses_new", title: "New" }) }) + : jsonResponse({}, 404), + ); + const session = await service.createSession({ directory: "/tmp/project", title: "New" }); + expect(session.id).toBe("ses_new"); + const body = JSON.parse(requests[0]?.body ?? "{}") as { title?: string; location?: { directory?: string } }; + expect(body.title).toBe("New"); + expect(body.location?.directory).toBe("/tmp/project"); + }); + + test("prompt submission sends the text", async () => { + const { service, requests } = makeService(({ url, method }) => + url.includes("/prompt") && method === "POST" + ? jsonResponse({ data: { id: "msg_1", text: "hi", type: "user", time: { created: 1 } } }) + : jsonResponse({}, 404), + ); + await service.sendPrompt("ses_test123", "hello world"); + const body = JSON.parse(requests[0]?.body ?? "{}") as { text?: string; sessionID?: string }; + expect(body.text).toBe("hello world"); + expect(requests[0]?.url).toContain("/api/session/ses_test123/prompt"); + }); + + test("rename sends the new title via patch", async () => { + const { service, requests } = makeService(({ method }) => + method === "PATCH" ? new Response(null, { status: 204 }) : jsonResponse({}, 404), + ); + await service.renameSession("ses_test123", "Renamed"); + const body = JSON.parse(requests[0]?.body ?? "{}") as { title?: string }; + expect(body.title).toBe("Renamed"); + expect(requests[0]?.method).toBe("PATCH"); + }); + + test("delete issues a delete request", async () => { + const { service, requests } = makeService(({ method }) => + method === "DELETE" ? new Response(null, { status: 204 }) : jsonResponse({}, 404), + ); + await service.deleteSession("ses_test123"); + expect(requests[0]?.method).toBe("DELETE"); + expect(requests[0]?.url).toContain("/api/session/ses_test123"); + }); + + test("auth headers from the endpoint are attached", async () => { + const { fetch, requests } = fakeFetch(({ url }) => + url.endsWith("/api/info") ? jsonResponse(serverInfoFixture()) : jsonResponse({}, 404), + ); + const service = createOpenCodeService( + { url: "http://127.0.0.1:45999", headers: { authorization: "Basic dXNlcjpwYXNz" } }, + { fetch }, + ); + await service.info(); + expect(requests[0]?.headers["authorization"]).toBe("Basic dXNlcjpwYXNz"); + }); + + test("timeout and abort errors map to a timeout error", () => { + const timeout = mapOpenCodeError(new DOMException("aborted due to timeout", "TimeoutError")); + expect(timeout.kind).toBe("timeout"); + const aborted = mapOpenCodeError(new DOMException("aborted", "AbortError")); + expect(aborted.kind).toBe("timeout"); + }); + + test("wait is abortable and does not hang without a timeout", async () => { + const { service } = makeService(() => new Promise(() => {})); + const controller = new AbortController(); + controller.abort(); + try { + await service.wait("ses_test123", controller.signal); + throw new Error("expected wait() to reject"); + } catch (error) { + expect(isOpenCodeError(error)).toBe(true); + } + }); + + test("pending permissions map every request to its session", async () => { + const { service, requests } = makeService(({ url }) => + url.includes("/api/permission/request") + ? jsonResponse({ data: [{ sessionID: "ses_a" }, { sessionID: "ses_b" }] }) + : jsonResponse({}, 404), + ); + const waiting = await service.pendingPermissions(); + expect(requests[0]?.url).toContain("/api/permission/request"); + expect([...waiting].sort()).toEqual(["ses_a", "ses_b"]); + }); + + test("pending forms map every form to its session", async () => { + const { service, requests } = makeService(({ url }) => + url.includes("/api/form") + ? jsonResponse({ data: [{ sessionID: "ses_q", id: "frm_1" }] }) + : jsonResponse({}, 404), + ); + const waiting = await service.pendingForms(); + expect(requests[0]?.url).toContain("/api/form"); + expect([...waiting]).toEqual(["ses_q"]); + }); + + test("error mapping normalizes unknown values", () => { + const mapped = mapOpenCodeError(undefined); + expect(isOpenCodeError(mapped)).toBe(true); + expect(mapped.kind).toBe("rejected"); + }); +}); diff --git a/extensions/opencode/test/prompts.test.ts b/extensions/opencode/test/prompts.test.ts new file mode 100644 index 000000000..39fbe6435 --- /dev/null +++ b/extensions/opencode/test/prompts.test.ts @@ -0,0 +1,43 @@ +import { describe, test, expect } from "bun:test"; +import { buildExplainPrompt, buildReviewPrompt } from "../src/lib/prompts"; + +describe("review prompts", () => { + test("cover working tree, staged, and branch scopes", () => { + const working = buildReviewPrompt("working"); + expect(working).toContain("working tree changes"); + expect(working).toContain("Do not modify files."); + + const staged = buildReviewPrompt("staged"); + expect(staged).toContain("staged changes"); + + const branch = buildReviewPrompt("branch", { current: "feature/x", default: "main" }); + expect(branch).toContain("branch feature/x relative to main"); + + const branchWithoutBase = buildReviewPrompt("branch", { current: "main", default: "main" }); + expect(branchWithoutBase).toContain("branch main"); + expect(branchWithoutBase).not.toContain("relative to main"); + }); + + test("can focus on one file", () => { + const prompt = buildReviewPrompt("working", undefined, "src/a.ts"); + expect(prompt).toContain("working tree changes"); + expect(prompt).toContain("`src/a.ts`"); + }); +}); + +describe("explain prompts", () => { + test("are scope-aware", () => { + const working = buildExplainPrompt("working"); + expect(working).toContain("Explain the current working tree changes"); + + const staged = buildExplainPrompt("staged"); + expect(staged).toContain("staged changes (git diff --cached)"); + + const branch = buildExplainPrompt("branch", { current: "feature/x", default: "main" }); + expect(branch).toContain("branch feature/x relative to main"); + + const focused = buildExplainPrompt("staged", undefined, "src/a.ts"); + expect(focused).toContain("`src/a.ts`"); + expect(focused).toContain("Do not modify files."); + }); +}); diff --git a/extensions/opencode/test/session-status.test.ts b/extensions/opencode/test/session-status.test.ts new file mode 100644 index 000000000..5f17dc1f2 --- /dev/null +++ b/extensions/opencode/test/session-status.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { sessionStatus, statusLabel } from "../src/lib/session-status"; +import { sessionFixture } from "./helpers"; +import type { SessionInfo } from "../src/lib/opencode/types"; + +function asSession(overrides: Record = {}): SessionInfo { + return sessionFixture(overrides) as unknown as SessionInfo; +} + +describe("session status", () => { + test("running sessions are Working", () => { + const session = asSession(); + const status = sessionStatus(session, { active: { [session.id]: { type: "running" } }, waiting: new Set() }); + expect(status).toBe("working"); + expect(statusLabel(status)).toBe("Working"); + }); + + test("sessions with pending permission requests are Waiting", () => { + const session = asSession(); + const status = sessionStatus(session, { active: {}, waiting: new Set([session.id]) }); + expect(status).toBe("waiting"); + expect(statusLabel(status)).toBe("Waiting for Input"); + }); + + test("failed outcomes are Failed", () => { + const session = asSession({ outcome: "failed" }); + const status = sessionStatus(session, { active: {}, waiting: new Set() }); + expect(status).toBe("failed"); + expect(statusLabel(status)).toBe("Failed"); + }); + + test("everything else is Idle", () => { + const session = asSession({ outcome: "succeeded" }); + const status = sessionStatus(session, { active: {}, waiting: new Set() }); + expect(status).toBe("idle"); + expect(statusLabel(status)).toBe("Idle"); + }); + + test("waiting wins over working and failure", () => { + const session = asSession({ outcome: "failed" }); + const status = sessionStatus(session, { + active: { [session.id]: { type: "running" } }, + waiting: new Set([session.id]), + }); + expect(status).toBe("waiting"); + }); +}); diff --git a/extensions/opencode/test/setup.ts b/extensions/opencode/test/setup.ts new file mode 100644 index 000000000..2736807d6 --- /dev/null +++ b/extensions/opencode/test/setup.ts @@ -0,0 +1,58 @@ +import { mock } from "bun:test"; + +/** + * Install the `@vicinae/api` stubs for one test file. The Vicinae runtime is + * not available under bun test, so the surface the extension touches is + * stubbed before the file's dynamic imports run. Call this at the top of a + * test file, before importing modules that touch `@vicinae/api`. + */ +export function installVicinaeStubs(): VicinaeStubs { + const state = { + preferences: {} as Record, + spawnedCommands: [] as string[][], + }; + + const noop = () => {}; + const stubComponent = () => null; + + mock.module("@vicinae/api", () => ({ + getPreferenceValues: () => state.preferences, + runInTerminal: async (args: string[]) => { + state.spawnedCommands.push([...args]); + }, + sendDesktopNotification: async () => {}, + showToast: async () => ({ hide: async () => {} }), + confirmAlert: async () => true, + clearSearchBar: async () => {}, + useNavigation: () => ({ push: noop, pop: noop }), + Action: Object.assign(stubComponent, { + SubmitForm: stubComponent, + Open: stubComponent, + OpenWith: stubComponent, + Push: stubComponent, + CopyToClipboard: stubComponent, + Paste: stubComponent, + Trash: stubComponent, + }), + ActionPanel: stubComponent, + Alert: { ActionStyle: { Default: "default", Destructive: "destructive", Cancel: "cancel" } }, + List: Object.assign(stubComponent, { + Dropdown: Object.assign(stubComponent, { Item: stubComponent, Section: stubComponent }), + Item: Object.assign(stubComponent, { Detail: stubComponent }), + Section: stubComponent, + EmptyView: stubComponent, + }), + Detail: Object.assign(stubComponent, { + Metadata: Object.assign(stubComponent, { Label: stubComponent, Separator: stubComponent }), + }), + Toast: { Style: { Success: "success", Failure: "failure", Animated: "animated" } }, + Icon: new Proxy({}, { get: (_target, property) => String(property) }), + })); + + return state; +} + +export interface VicinaeStubs { + preferences: Record; + spawnedCommands: string[][]; +} diff --git a/extensions/opencode/tsconfig.json b/extensions/opencode/tsconfig.json new file mode 100644 index 000000000..49da69da8 --- /dev/null +++ b/extensions/opencode/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "types": ["node", "bun"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"] +} From 407d0972561108966cb6aedc4d65d2e0cb8e2c24 Mon Sep 17 00:00:00 2001 From: adityazxxx Date: Tue, 22 Sep 2026 12:25:29 +0700 Subject: [PATCH 2/5] fix: address automated review findings --- extensions/opencode/package.json | 2 +- extensions/opencode/src/ask.tsx | 19 +++++++++++++-- .../opencode/src/components/error-state.tsx | 3 ++- .../opencode/src/components/model-picker.tsx | 12 ++++++++-- extensions/opencode/src/lib/git.ts | 2 +- extensions/opencode/src/lib/models.ts | 7 ++++-- .../opencode/src/lib/opencode/discovery.ts | 16 +++++++++++++ extensions/opencode/test/discovery.test.ts | 24 +++++++++++++++++++ extensions/opencode/test/git.test.ts | 12 +++++++--- 9 files changed, 85 insertions(+), 12 deletions(-) diff --git a/extensions/opencode/package.json b/extensions/opencode/package.json index 356f909cc..95bbef3c6 100644 --- a/extensions/opencode/package.json +++ b/extensions/opencode/package.json @@ -81,7 +81,7 @@ "name": "serverPassword", "type": "password", "title": "Server Password", - "description": "HTTP basic auth password for the configured server. Only used when a Server URL is set. Local services discovered automatically handle auth themselves.", + "description": "HTTP basic auth password for the configured server. Sent only over HTTPS or to local addresses. Local services discovered automatically handle auth themselves.", "required": false }, { diff --git a/extensions/opencode/src/ask.tsx b/extensions/opencode/src/ask.tsx index 42519ce8e..71a43f82b 100644 --- a/extensions/opencode/src/ask.tsx +++ b/extensions/opencode/src/ask.tsx @@ -223,9 +223,24 @@ export function AskView(props: { readonly endpoint: Endpoint; readonly config: C const handleModelChange = useCallback( (modelId: string) => { - setSelectedModelId(modelId); const model = parseModelRef(modelId); - if (sessionID && model) void service.switchModel(sessionID, model); + if (!model) return; + if (!sessionID) { + setSelectedModelId(modelId); + return; + } + void (async () => { + try { + await service.switchModel(sessionID, model); + setSelectedModelId(modelId); + } catch { + await showToast({ + style: Toast.Style.Failure, + title: "Model switch failed", + message: "Keeping the previous model.", + }); + } + })(); }, [service, sessionID], ); diff --git a/extensions/opencode/src/components/error-state.tsx b/extensions/opencode/src/components/error-state.tsx index 5f4a03a8a..69ef68bcd 100644 --- a/extensions/opencode/src/components/error-state.tsx +++ b/extensions/opencode/src/components/error-state.tsx @@ -15,7 +15,8 @@ export function OpenCodeErrorView(props: { readonly onRetry?: () => void; }): ReactNode { const [starting, setStarting] = useState(false); - const startable = props.error.kind === "unreachable"; + // Starting a local service cannot fix a configured remote URL. + const startable = props.error.kind === "unreachable" && !props.config.serverUrl; const start = async () => { setStarting(true); diff --git a/extensions/opencode/src/components/model-picker.tsx b/extensions/opencode/src/components/model-picker.tsx index a5b2163cf..05cee41ea 100644 --- a/extensions/opencode/src/components/model-picker.tsx +++ b/extensions/opencode/src/components/model-picker.tsx @@ -14,7 +14,7 @@ export function ModelPickerList(props: { readonly onPick: (model: ModelRef) => void; }): ReactNode { const { pop } = useNavigation(); - const { models, defaultModelID, failed } = useModels(props.endpoint); + const { models, defaultModelID, failed, loaded } = useModels(props.endpoint); if (failed) { return ( @@ -24,9 +24,17 @@ export function ModelPickerList(props: { ); } + if (loaded && models.length === 0) { + return ( + + + + ); + } + return ( { const baseArgs = scopeDiffArgs(scope, base); if (!baseArgs) return []; - const files = parseNumstat(await runner([...baseArgs, "--numstat", "--"])); + const files = parseNumstat(await runner([...baseArgs, "--numstat", "--no-renames", "--"])); if (scope === "working") { // `git diff` omits untracked files; a new file with secrets in it is // exactly what a review should catch, so include them via status. diff --git a/extensions/opencode/src/lib/models.ts b/extensions/opencode/src/lib/models.ts index a8cdba887..ce5882372 100644 --- a/extensions/opencode/src/lib/models.ts +++ b/extensions/opencode/src/lib/models.ts @@ -25,9 +25,11 @@ export interface ModelsState { readonly defaultModelID: string | undefined; /** The model list could not be loaded. */ readonly failed: boolean; + /** The first load finished, even when it returned no models. */ + readonly loaded: boolean; } -const EMPTY_MODELS: ModelsState = { models: [], defaultModelID: undefined, failed: false }; +const EMPTY_MODELS: ModelsState = { models: [], defaultModelID: undefined, failed: false, loaded: false }; /** Load the model list and OpenCode's default model for the lifetime of a view. */ export function useModels(endpoint: Endpoint): ModelsState { @@ -47,9 +49,10 @@ export function useModels(endpoint: Endpoint): ModelsState { models: list, defaultModelID: fallback ? modelValue(fallback) : undefined, failed: false, + loaded: true, }); } catch { - if (!controller.signal.aborted) setState({ ...EMPTY_MODELS, failed: true }); + if (!controller.signal.aborted) setState({ ...EMPTY_MODELS, failed: true, loaded: true }); } })(); return () => controller.abort(); diff --git a/extensions/opencode/src/lib/opencode/discovery.ts b/extensions/opencode/src/lib/opencode/discovery.ts index 8f52a7433..eb26171aa 100644 --- a/extensions/opencode/src/lib/opencode/discovery.ts +++ b/extensions/opencode/src/lib/opencode/discovery.ts @@ -37,6 +37,12 @@ function basicAuthHeaders(username?: string, password?: string): Record { expect(endpoint?.headers?.["authorization"]).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`); }); + test("remote http with a password is rejected", async () => { + try { + await resolveEndpoint({ serverUrl: "http://192.168.1.10:4096", serverPassword: "secret" }); + throw new Error("expected resolveEndpoint to reject"); + } catch (error) { + expect((error as Error).message).toBe("Password auth needs HTTPS on remote servers."); + } + }); + + test("remote https with a password sends basic auth", async () => { + const endpoint = await resolveEndpoint({ + serverUrl: "https://opencode.example.com", + serverUsername: "alice", + serverPassword: "wonderland", + }); + expect(endpoint?.headers?.["authorization"]).toBe(`Basic ${Buffer.from("alice:wonderland").toString("base64")}`); + }); + + test("remote http without a password is allowed", async () => { + const endpoint = await resolveEndpoint({ serverUrl: "http://192.168.1.10:4096" }); + expect(endpoint?.url).toBe("http://192.168.1.10:4096"); + expect(endpoint?.headers).toBeUndefined(); + }); + test("invalid endpoint is rejected", async () => { try { await resolveEndpoint({ serverUrl: "not-a-url" }); diff --git a/extensions/opencode/test/git.test.ts b/extensions/opencode/test/git.test.ts index 0a3dd5e54..613a276c9 100644 --- a/extensions/opencode/test/git.test.ts +++ b/extensions/opencode/test/git.test.ts @@ -35,9 +35,15 @@ describe("git output parsing", () => { ]); }); - test("numstat rename entries carry both paths", () => { - const files = parseNumstat("5\t2\told/name.ts\tnew/name.ts\n"); - expect(files).toEqual([{ path: "old/name.ts\tnew/name.ts", additions: 5, deletions: 2, untracked: false }]); + test("rename detection is off so every entry is a usable path", async () => { + const seen: string[][] = []; + const runner: GitRunner = async (args) => { + seen.push([...args]); + return ""; + }; + await listChangedFiles(runner, "working"); + const numstat = seen.find((args) => args.includes("--numstat")); + expect(numstat).toContain("--no-renames"); }); test("porcelain -z collects untracked paths", () => { From 6ea3d3b2945b9a565c8e6b25bcd661810a4d02eb Mon Sep 17 00:00:00 2001 From: adityazxxx Date: Wed, 23 Sep 2026 06:45:43 +0700 Subject: [PATCH 3/5] feat(session): add transcript view with session info --- extensions/opencode/README.md | 1 - extensions/opencode/package.json | 9 - .../opencode/src/components/session-list.tsx | 238 +++++++++++++----- .../src/components/session-transcript.tsx | 196 +++++++++++++++ extensions/opencode/src/lib/config.ts | 3 - extensions/opencode/src/lib/format.ts | 9 + .../opencode/src/lib/opencode/client.ts | 7 - extensions/opencode/src/lib/time.ts | 18 ++ extensions/opencode/test/config.test.ts | 3 - extensions/opencode/test/format.test.ts | 23 ++ .../opencode/test/opencode-client.test.ts | 12 - extensions/opencode/test/setup.ts | 12 + extensions/opencode/test/tildify.test.ts | 21 ++ extensions/opencode/test/time.test.ts | 33 +++ extensions/opencode/test/transcript.test.ts | 79 ++++++ 15 files changed, 565 insertions(+), 99 deletions(-) create mode 100644 extensions/opencode/src/components/session-transcript.tsx create mode 100644 extensions/opencode/src/lib/format.ts create mode 100644 extensions/opencode/test/format.test.ts create mode 100644 extensions/opencode/test/tildify.test.ts create mode 100644 extensions/opencode/test/time.test.ts create mode 100644 extensions/opencode/test/transcript.test.ts diff --git a/extensions/opencode/README.md b/extensions/opencode/README.md index 20a917801..069fa2e9d 100644 --- a/extensions/opencode/README.md +++ b/extensions/opencode/README.md @@ -56,7 +56,6 @@ The extension talks to an OpenCode V2 server using the official `@opencode/clien | Server Username | `opencode` | HTTP basic auth username for a configured server. | | Server Password | *(empty)* | HTTP basic auth password for a configured server. | | OpenCode Executable | *(auto-detect)* | OpenCode V2 executable used to open sessions. Auto-detects `opencode2` or `opencode` on PATH and verifies it is a V2 build. | -| Completion Notifications | off | Desktop notification when a session you explicitly waited on finishes. | ## Development commands diff --git a/extensions/opencode/package.json b/extensions/opencode/package.json index 95bbef3c6..cadf4ae29 100644 --- a/extensions/opencode/package.json +++ b/extensions/opencode/package.json @@ -91,15 +91,6 @@ "description": "Path or name of the OpenCode V2 executable used to open sessions, for example /usr/local/bin/opencode. Leave empty to auto-detect opencode2 or opencode on PATH.", "required": false, "placeholder": "opencode" - }, - { - "name": "notifyOnCompletion", - "type": "checkbox", - "title": "Completion Notifications", - "label": "Notify when a watched session finishes", - "description": "Send a desktop notification when a session you explicitly waited on finishes. Only applies while a Vicinae window is open.", - "required": false, - "default": false } ], "scripts": { diff --git a/extensions/opencode/src/components/session-list.tsx b/extensions/opencode/src/components/session-list.tsx index 46a900cb2..ff81cc666 100644 --- a/extensions/opencode/src/components/session-list.tsx +++ b/extensions/opencode/src/components/session-list.tsx @@ -1,15 +1,16 @@ -import { Action, ActionPanel, Icon, List, Toast, showToast, sendDesktopNotification, useNavigation } from "@vicinae/api"; +import { Action, ActionPanel, Icon, List, Toast, showToast, useNavigation } from "@vicinae/api"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createOpenCodeService, type OpenCodeService } from "../lib/opencode/client"; import type { Endpoint } from "../lib/opencode/discovery"; import { mapOpenCodeError, type OpenCodeError } from "../lib/opencode/errors"; import type { Config } from "../lib/config"; import { sessionStatus, statusLabel, type SessionStatusKind } from "../lib/session-status"; -import { relativeTime } from "../lib/time"; +import { relativeTime, timeBucket, TIME_BUCKETS } from "../lib/time"; import type { SessionActive, SessionInfo } from "../lib/opencode/types"; import { useLiveEvents } from "../lib/live-events"; import { DeleteSessionAction, OpenSessionAction, showActionError } from "./actions"; import { PromptForm, RenameSessionForm } from "./prompt-form"; +import { SessionTranscriptView } from "./session-transcript"; import { OpenCodeErrorView } from "./error-state"; const BASE_PAGE_SIZE = 100; @@ -40,51 +41,37 @@ function statusIcon(kind: SessionStatusKind) { } } -function sessionDetailMarkdown(session: SessionInfo): string { - const lines = [ - `**${session.title || "Untitled session"}**`, - "", - `- Directory: \`${session.location?.directory ?? "unknown"}\``, - `- Agent: ${session.agent ?? "default"}`, - `- Model: ${session.model ? `${session.model.providerID}/${session.model.id}` : "default"}`, - `- Updated: ${relativeTime(session.time.updated) ?? "unknown"}`, - ]; - if (session.outcome) lines.push(`- Last outcome: ${session.outcome}`); - if (session.cost) lines.push(`- Cost: $${session.cost.toFixed(4)}`); - if (session.tokens) lines.push(`- Tokens: ${Math.round(session.tokens.input + session.tokens.output)}`); - lines.push("", `Session ID: \`${session.id}\``); - return lines.join("\n"); +/** Abbreviate the home directory so rows stay short: /home/me/work -> ~/work */ +export function tildify(directory: string | undefined): string | undefined { + if (!directory) return undefined; + const home = process.env["HOME"]; + // Match the home directory itself or a full path segment below it, never + // a directory that merely starts with the same characters. + if (home && home !== "/" && (directory === home || directory.startsWith(`${home}/`))) { + return `~${directory.slice(home.length)}`; + } + return directory; } -/** Wait for a running session to finish, then report completion. */ -function WaitForCompletionAction(props: { +/** Stop a working session through the official interrupt endpoint. */ +function StopSessionAction(props: { readonly sessionID: string; readonly service: OpenCodeService; - readonly notifyOnCompletion: boolean; readonly onChanged: () => void; }): ReactNode { return ( { void (async () => { - const toast = await showToast({ style: Toast.Style.Animated, title: "Waiting for OpenCode" }); try { - await props.service.wait(props.sessionID); - await showToast({ style: Toast.Style.Success, title: "OpenCode session finished" }); - if (props.notifyOnCompletion) { - await sendDesktopNotification({ - title: "OpenCode session finished", - body: "The session you were waiting on has finished.", - }); - } + await props.service.interrupt(props.sessionID); + await showToast({ style: Toast.Style.Success, title: "Session stopped" }); props.onChanged(); } catch (error) { - await showActionError(error, "Wait failed"); - } finally { - void toast.hide(); + await showActionError(error, "Failed to stop OpenCode"); } })(); }} @@ -101,14 +88,32 @@ function SessionActions(props: { readonly onChanged: () => void; }): ReactNode { const { push } = useNavigation(); + const directory = props.session.location?.directory; + const resumeCommand = directory + ? `cd ${directory} && ${props.config.openCodePath || "opencode"} --session ${props.session.id}` + : `${props.config.openCodePath || "opencode"} --session ${props.session.id}`; return ( + + push( + , + ) + } + /> {props.status === "working" ? ( - ) : null} @@ -139,11 +143,19 @@ function SessionActions(props: { ) } /> + + {directory ? ( + + ) : null} ; waiting: ReadonlySet }; + readonly config: Config; + readonly endpoint: Endpoint; + readonly service: OpenCodeService; + readonly onChanged: () => void; +}): ReactNode { + const { session } = props; + const status = sessionStatus(session, props.statusInputs); + const directory = session.location?.directory; + const subtitle = tildify(directory); + // Idle is the normal state for most sessions, so it gets no badge; only + // Working, Waiting for Input, and Failed are worth surfacing. + const accessories: List.Item.Accessory[] = []; + if (status !== "idle") accessories.push(statusAccessory(status)); + accessories.push({ text: relativeTime(session.time.updated) }); + return ( + + } + /> + ); +} + /** * Shared session browser. Used by the OpenCode Sessions command and pushed * from the Projects view with a project filter. One source of truth: the @@ -177,6 +228,7 @@ export function SessionListView(props: { const [waiting, setWaiting] = useState>(new Set()); const [error, setError] = useState(null); const [reloadKey, setReloadKey] = useState(0); + const [directoryFilter, setDirectoryFilter] = useState("all"); const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); @@ -269,6 +321,42 @@ export function SessionListView(props: { // Live refresh through the official event stream while this view is mounted. useLiveEvents(service, refreshCallback); + // Projects that exist among the loaded sessions, newest activity first. + const directories = useMemo(() => { + const last = new Map(); + for (const session of sessions) { + const directory = session.location?.directory; + if (!directory) continue; + const current = last.get(directory) ?? 0; + if (session.time.updated > current) last.set(directory, session.time.updated); + } + return [...last.entries()].sort((a, b) => b[1] - a[1]).map(([directory]) => directory); + }, [sessions]); + + // The list stays flat during server search; grouping there would fight + // the relevance order OpenCode already decided. + const visible = useMemo( + () => + directoryFilter === "all" + ? sessions + : sessions.filter((session) => session.location?.directory === directoryFilter), + [sessions, directoryFilter], + ); + + const grouped = useMemo(() => { + const byBucket = new Map(); + for (const session of visible) { + const bucket = timeBucket(session.time.updated); + const bucketItems = byBucket.get(bucket) ?? []; + bucketItems.push(session); + byBucket.set(bucket, bucketItems); + } + return TIME_BUCKETS.filter((bucket) => byBucket.has(bucket)).map((bucket) => ({ + bucket, + items: byBucket.get(bucket) as SessionInfo[], + })); + }, [visible]); + if (error) { return setReloadKey((v) => v + 1)} />; } @@ -297,12 +385,25 @@ export function SessionListView(props: { return ( 1 ? ( + setDirectoryFilter(value)} + > + + {directories.map((directory) => ( + + ))} + + ) : undefined + } pagination={{ hasMore: !!cursor && !searchActive, onLoadMore: () => void loadMore() }} > {sessions.length === 0 && !isLoading ? ( @@ -315,31 +416,40 @@ export function SessionListView(props: { : "Create a session with OpenCode Ask or OpenCode New Session." } /> + ) : directoryFilter !== "all" && visible.length === 0 && !isLoading ? ( + + ) : searchActive ? ( + visible.map((session) => ( + setReloadKey((v) => v + 1)} + /> + )) ) : ( - sessions.map((session) => { - const status = sessionStatus(session, statusInputs); - return ( - } - actions={ - setReloadKey((v) => v + 1)} - /> - } - /> - ); - }) + grouped.map(({ bucket, items }) => ( + 0 ? `${items.length}` : ""}> + {items.map((session) => ( + setReloadKey((v) => v + 1)} + /> + ))} + + )) )} ); diff --git a/extensions/opencode/src/components/session-transcript.tsx b/extensions/opencode/src/components/session-transcript.tsx new file mode 100644 index 000000000..3ab93dd3e --- /dev/null +++ b/extensions/opencode/src/components/session-transcript.tsx @@ -0,0 +1,196 @@ +import { Action, ActionPanel, Detail, Toast, showToast } from "@vicinae/api"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { createOpenCodeService } from "../lib/opencode/client"; +import type { Endpoint } from "../lib/opencode/discovery"; +import { mapOpenCodeError } from "../lib/opencode/errors"; +import type { Config } from "../lib/config"; +import { formatTokens } from "../lib/format"; +import { OpenSessionAction } from "./actions"; +import type { SessionInfo } from "../lib/opencode/types"; + +type LoadingToast = Awaited>; + +interface TranscriptMessage { + readonly id: string; + readonly role: "user" | "assistant"; + readonly text: string; + readonly time: number | undefined; + readonly tools: readonly string[]; +} + +function messageText(message: { type: string; text?: unknown; content?: unknown }): string { + // User messages carry their text directly; assistant messages carry text + // parts inside `content`. + if (message.type === "user") return typeof message.text === "string" ? message.text : ""; + if (message.type !== "assistant") return ""; + const parts = (message.content ?? []) as Array<{ type: string; text?: string }>; + return parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join(""); +} + +function messageTools(message: { type: string; content?: unknown }): readonly string[] { + if (message.type !== "assistant") return []; + const parts = (message.content ?? []) as Array<{ type: string; name?: string }>; + return parts.filter((part) => part.type === "tool" && part.name).map((part) => part.name as string); +} + +export function toTranscript(messages: readonly unknown[]): TranscriptMessage[] { + const out: TranscriptMessage[] = []; + for (const raw of messages) { + const message = raw as { id?: string; time?: { created?: number }; type: string; text?: unknown; content?: unknown }; + const role = message.type === "user" || message.type === "assistant" ? message.type : undefined; + if (!role) continue; + const text = messageText(message); + const tools = messageTools(message); + if (!text && tools.length === 0) continue; + out.push({ + id: message.id ?? `m${out.length}`, + role, + text, + time: message.time?.created, + tools, + }); + } + return out; +} + +/** + * Chat-style markdown in natural reading order (oldest at top, newest at + * bottom), like a chat app. Turns that produced no text are left out; their + * tool usage lives in the metadata pane. + */ +export function transcriptMarkdown(messages: readonly TranscriptMessage[], newestFirst = false): string { + const ordered = newestFirst ? [...messages].reverse() : messages; + const withText = ordered.filter((message) => message.text); + if (withText.length === 0) { + const tools = uniqueTools(messages); + return tools.length > 0 + ? `**OpenCode**\n\nTool calls only, no text output.\n\n**Tools used:** ${tools.join(", ")}` + : ""; + } + return withText + .map((message) => { + const author = message.role === "user" ? "**You**" : "**OpenCode**"; + const toolNote = message.tools.length > 0 ? `\n\n**Tools used:** ${message.tools.join(", ")}` : ""; + return `${author}\n\n${message.text}${toolNote}`; + }) + .join("\n\n---\n\n"); +} + +/** Tool names used across a transcript, first use first, without duplicates. */ +function uniqueTools(messages: readonly TranscriptMessage[]): readonly string[] { + const seen = new Set(); + for (const message of messages) { + for (const tool of message.tools) seen.add(tool); + } + return [...seen]; +} + +/** The text of the newest assistant message that produced text. */ +export function lastAssistantText(messages: readonly TranscriptMessage[]): string | undefined { + return [...messages].reverse().find((message) => message.role === "assistant" && message.text)?.text; +} + +/** + * Full transcript of a session as one markdown window, like the Ask + * conversation view, with session info in the metadata pane. Pushed from the + * session list. + */ +export function SessionTranscriptView(props: { + readonly endpoint: Endpoint; + readonly config: Config; + readonly session: SessionInfo; +}): ReactNode { + const [messages, setMessages] = useState(undefined); + const [failed, setFailed] = useState(undefined); + const service = useMemo(() => createOpenCodeService(props.endpoint), [props.endpoint]); + + useEffect(() => { + const controller = new AbortController(); + void (async () => { + try { + const raw = await service.messages(props.session.id); + if (controller.signal.aborted) return; + setMessages(toTranscript(raw as readonly unknown[])); + } catch (caught) { + if (controller.signal.aborted) return; + setFailed(mapOpenCodeError(caught).message); + } + })(); + return () => controller.abort(); + }, [props.session.id, service]); + + const session = props.session; + const lastText = messages ? lastAssistantText(messages) : undefined; + const tools = messages ? uniqueTools(messages) : []; + const markdown = failed + ? `**Transcript could not be loaded.**\n\n${failed}` + : messages && messages.length > 0 + ? transcriptMarkdown(messages) + : "…"; + + // Loading feedback: Detail has no isLoading prop, so an animated toast + // covers the wait and is hidden once the transcript arrives. + const [loadingToast, setLoadingToast] = useState(undefined); + useEffect(() => { + if (messages === undefined && failed === undefined && !loadingToast) { + void showToast({ style: Toast.Style.Animated, title: "Loading transcript" }).then(setLoadingToast); + } + if ((messages !== undefined || failed !== undefined) && loadingToast) { + void loadingToast.hide(); + setLoadingToast(undefined); + } + }, [messages, failed, loadingToast]); + + return ( + + + + + {tools.length > 0 ? ( + + ) : null} + + + + + + + } + actions={ + + + {lastText ? ( + + ) : null} + {messages && messages.length > 0 ? ( + + ) : null} + + } + /> + ); +} diff --git a/extensions/opencode/src/lib/config.ts b/extensions/opencode/src/lib/config.ts index 162059dd5..372ef5911 100644 --- a/extensions/opencode/src/lib/config.ts +++ b/extensions/opencode/src/lib/config.ts @@ -5,7 +5,6 @@ export interface ExtensionPreferences { readonly serverUsername?: string; readonly serverPassword?: string; readonly openCodePath?: string; - readonly notifyOnCompletion?: boolean; } export interface Config { @@ -13,7 +12,6 @@ export interface Config { readonly serverUsername: string; readonly serverPassword?: string; readonly openCodePath?: string; - readonly notifyOnCompletion: boolean; } /** Load and normalize extension preferences. Sensible defaults, nothing required. */ @@ -27,6 +25,5 @@ export function loadConfig(): Config { serverUsername: preferences.serverUsername?.trim() || "opencode", ...(serverPassword ? { serverPassword } : {}), ...(openCodePath ? { openCodePath } : {}), - notifyOnCompletion: preferences.notifyOnCompletion ?? false, }; } diff --git a/extensions/opencode/src/lib/format.ts b/extensions/opencode/src/lib/format.ts new file mode 100644 index 000000000..54bdd99bf --- /dev/null +++ b/extensions/opencode/src/lib/format.ts @@ -0,0 +1,9 @@ +/** Compact token count for badges and metadata: 999, "1.5k", "20k", "1.5M". */ +export function formatTokens(count: number): string { + if (count < 1000) return `${count}`; + if (count < 10_000) { + return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + if (count < 1_000_000) return `${Math.round(count / 1000)}k`; + return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; +} diff --git a/extensions/opencode/src/lib/opencode/client.ts b/extensions/opencode/src/lib/opencode/client.ts index a5d7e691c..04a188dde 100644 --- a/extensions/opencode/src/lib/opencode/client.ts +++ b/extensions/opencode/src/lib/opencode/client.ts @@ -52,7 +52,6 @@ export interface OpenCodeService { sendPrompt(sessionID: string, text: string): Promise; renameSession(id: string, title: string): Promise; deleteSession(id: string): Promise; - wait(sessionID: string, signal?: AbortSignal): Promise; interrupt(sessionID: string): Promise; models(): Promise; defaultModel(): Promise; @@ -185,12 +184,6 @@ export function createOpenCodeService( deleteSession: (id) => invoke((client, options) => client.session.remove({ sessionID: id }, options)), - wait: (sessionID, signal) => - invoke((client, options) => client.session.wait({ sessionID }, options), { - timeoutMs: undefined, - signal, - }), - interrupt: (sessionID) => invoke((client, options) => client.session.interrupt({ sessionID }, options)).then(() => undefined), diff --git a/extensions/opencode/src/lib/time.ts b/extensions/opencode/src/lib/time.ts index e95ee9525..550263d00 100644 --- a/extensions/opencode/src/lib/time.ts +++ b/extensions/opencode/src/lib/time.ts @@ -13,3 +13,21 @@ export function relativeTime(timestamp: number | undefined | null): string | und if (days < 7) return `${days}d ago`; return new Date(timestamp).toLocaleDateString(); } + +/** Group timestamps into the buckets the session list shows. */ +export function timeBucket(timestamp: number, now: number = Date.now()): "Today" | "Yesterday" | "This Week" | "This Month" | "Older" { + const startOfDay = (ms: number) => { + const date = new Date(ms); + date.setHours(0, 0, 0, 0); + return date.getTime(); + }; + const today = startOfDay(now); + if (timestamp >= today) return "Today"; + if (timestamp >= today - 86_400_000) return "Yesterday"; + if (timestamp >= today - 6 * 86_400_000) return "This Week"; + if (timestamp >= today - 29 * 86_400_000) return "This Month"; + return "Older"; +} + +/** Section order for time bucketed lists. */ +export const TIME_BUCKETS = ["Today", "Yesterday", "This Week", "This Month", "Older"] as const; diff --git a/extensions/opencode/test/config.test.ts b/extensions/opencode/test/config.test.ts index 76e1b7699..5fefd8d6b 100644 --- a/extensions/opencode/test/config.test.ts +++ b/extensions/opencode/test/config.test.ts @@ -22,7 +22,6 @@ describe("config", () => { expect(config.serverUsername).toBe("opencode"); expect(config.serverPassword).toBeUndefined(); expect(config.openCodePath).toBeUndefined(); - expect(config.notifyOnCompletion).toBe(false); }); test("configured values are normalized", () => { @@ -31,14 +30,12 @@ describe("config", () => { serverUsername: " alice ", serverPassword: " secret ", openCodePath: " /usr/local/bin/opencode ", - notifyOnCompletion: true, }; const config = loadConfig(); expect(config.serverUrl).toBe("http://127.0.0.1:49374"); expect(config.serverUsername).toBe("alice"); expect(config.serverPassword).toBe("secret"); expect(config.openCodePath).toBe("/usr/local/bin/opencode"); - expect(config.notifyOnCompletion).toBe(true); }); }); diff --git a/extensions/opencode/test/format.test.ts b/extensions/opencode/test/format.test.ts new file mode 100644 index 000000000..c00cc5f17 --- /dev/null +++ b/extensions/opencode/test/format.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { formatTokens } from "../src/lib/format"; + +describe("token formatting", () => { + test("small counts stay raw", () => { + expect(formatTokens(0)).toBe("0"); + expect(formatTokens(999)).toBe("999"); + }); + + test("thousands read as k", () => { + expect(formatTokens(1000)).toBe("1k"); + expect(formatTokens(1500)).toBe("1.5k"); + expect(formatTokens(9800)).toBe("9.8k"); + expect(formatTokens(20480)).toBe("20k"); + expect(formatTokens(999_999)).toBe("1000k"); + }); + + test("millions read as M", () => { + expect(formatTokens(1_000_000)).toBe("1M"); + expect(formatTokens(1_500_000)).toBe("1.5M"); + expect(formatTokens(2_000_000)).toBe("2M"); + }); +}); diff --git a/extensions/opencode/test/opencode-client.test.ts b/extensions/opencode/test/opencode-client.test.ts index 0cd9f22aa..3109bf1fe 100644 --- a/extensions/opencode/test/opencode-client.test.ts +++ b/extensions/opencode/test/opencode-client.test.ts @@ -176,18 +176,6 @@ describe("OpenCode client adapter", () => { expect(aborted.kind).toBe("timeout"); }); - test("wait is abortable and does not hang without a timeout", async () => { - const { service } = makeService(() => new Promise(() => {})); - const controller = new AbortController(); - controller.abort(); - try { - await service.wait("ses_test123", controller.signal); - throw new Error("expected wait() to reject"); - } catch (error) { - expect(isOpenCodeError(error)).toBe(true); - } - }); - test("pending permissions map every request to its session", async () => { const { service, requests } = makeService(({ url }) => url.includes("/api/permission/request") diff --git a/extensions/opencode/test/setup.ts b/extensions/opencode/test/setup.ts index 2736807d6..7a7a527bc 100644 --- a/extensions/opencode/test/setup.ts +++ b/extensions/opencode/test/setup.ts @@ -46,6 +46,18 @@ export function installVicinaeStubs(): VicinaeStubs { Metadata: Object.assign(stubComponent, { Label: stubComponent, Separator: stubComponent }), }), Toast: { Style: { Success: "success", Failure: "failure", Animated: "animated" } }, + Form: Object.assign(stubComponent, { + TextArea: stubComponent, + TextField: stubComponent, + PasswordField: stubComponent, + Dropdown: Object.assign(stubComponent, { Item: stubComponent }), + Checkbox: stubComponent, + DatePicker: stubComponent, + Description: stubComponent, + TagPicker: Object.assign(stubComponent, { Item: stubComponent }), + FilePicker: stubComponent, + }), + Keyboard: { Shortcut: { Common: {} } }, Icon: new Proxy({}, { get: (_target, property) => String(property) }), })); diff --git a/extensions/opencode/test/tildify.test.ts b/extensions/opencode/test/tildify.test.ts new file mode 100644 index 000000000..e0d7b36ea --- /dev/null +++ b/extensions/opencode/test/tildify.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { installVicinaeStubs } from "./setup"; + +installVicinaeStubs(); + +const { tildify } = await import("../src/components/session-list"); + +describe("tildify", () => { + test("home prefix becomes ~", () => { + process.env["HOME"] = "/home/tester"; + expect(tildify("/home/tester/work/project")).toBe("~/work/project"); + expect(tildify("/home/tester")).toBe("~"); + expect(tildify("/opt/elsewhere")).toBe("/opt/elsewhere"); + expect(tildify(undefined)).toBeUndefined(); + }); + + test("a shared prefix does not collapse", () => { + process.env["HOME"] = "/home/test"; + expect(tildify("/home/testing/project")).toBe("/home/testing/project"); + }); +}); diff --git a/extensions/opencode/test/time.test.ts b/extensions/opencode/test/time.test.ts new file mode 100644 index 000000000..911339262 --- /dev/null +++ b/extensions/opencode/test/time.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { relativeTime, timeBucket } from "../src/lib/time"; + +const DAY = 86_400_000; + +describe("relative time", () => { + test("recent timestamps read naturally", () => { + const now = Date.now(); + expect(relativeTime(now)).toBe("just now"); + expect(relativeTime(now - 5 * 60_000)).toBe("5m ago"); + expect(relativeTime(now - 3 * 3_600_000)).toBe("3h ago"); + expect(relativeTime(now - 2 * DAY)).toBe("2d ago"); + expect(relativeTime(undefined)).toBeUndefined(); + expect(relativeTime(now + 60_000)).toBe("just now"); // future timestamps clamp + }); +}); + +describe("time buckets", () => { + // A fixed reference so bucket edges are stable regardless of run time. + const NOW = Date.UTC(2026, 8, 22, 12, 0, 0); + + test("timestamps fall into the right section", () => { + expect(timeBucket(NOW, NOW)).toBe("Today"); + expect(timeBucket(NOW - DAY, NOW)).toBe("Yesterday"); + expect(timeBucket(NOW - 3 * DAY, NOW)).toBe("This Week"); + expect(timeBucket(NOW - 10 * DAY, NOW)).toBe("This Month"); + expect(timeBucket(NOW - 60 * DAY, NOW)).toBe("Older"); + }); + + test("late yesterday is not today", () => { + expect(timeBucket(NOW - DAY - 3_600_000, NOW)).toBe("Yesterday"); + }); +}); diff --git a/extensions/opencode/test/transcript.test.ts b/extensions/opencode/test/transcript.test.ts new file mode 100644 index 000000000..c0e5a44d3 --- /dev/null +++ b/extensions/opencode/test/transcript.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { installVicinaeStubs } from "./setup"; + +installVicinaeStubs(); + +const { toTranscript, transcriptMarkdown, lastAssistantText } = await import( + "../src/components/session-transcript" +); + +describe("transcript parsing", () => { + test("user messages carry text directly, assistant messages carry content parts", () => { + // Shapes come straight from the V2 session context API: user messages + // have `type: "user"` and a top level `text`; assistant messages have + // `content` parts. Neither has a `role` field. + const messages = [ + { id: "m1", type: "compaction", time: { created: 1 } }, + { + id: "m2", + type: "user", + time: { created: 2 }, + text: "fix the login bug", + files: [], + agents: [], + }, + { + id: "m3", + type: "assistant", + time: { created: 3 }, + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "I will fix it." }, + { type: "tool", name: "edit", state: {} }, + { type: "text", text: " Done." }, + ], + }, + { id: "m4", type: "idle", time: { created: 4 } }, + ]; + const transcript = toTranscript(messages); + expect(transcript).toHaveLength(2); + expect(transcript[0]).toEqual({ + id: "m2", + role: "user", + text: "fix the login bug", + time: 2, + tools: [], + }); + expect(transcript[1]?.role).toBe("assistant"); + expect(transcript[1]?.text).toBe("I will fix it. Done."); + expect(transcript[1]?.tools).toEqual(["edit"]); + }); + + test("markdown skips text-less turns and can flip to newest first", () => { + const messages = [ + { id: "m1", role: "user" as const, text: "first question", time: 1, tools: [] }, + { id: "m2", role: "assistant" as const, text: "", time: 2, tools: ["read", "grep"] }, + { id: "m3", role: "user" as const, text: "and then?", time: 3, tools: [] }, + { id: "m4", role: "assistant" as const, text: "All done.", time: 4, tools: ["edit"] }, + ]; + const natural = transcriptMarkdown(messages); + expect(natural).not.toContain("(tool call only)"); + expect(natural.indexOf("first question")).toBeLessThan(natural.indexOf("All done.")); + + const newestFirst = transcriptMarkdown(messages, true); + expect(newestFirst.indexOf("All done.")).toBeLessThan(newestFirst.indexOf("first question")); + // The text-less turn stays out of the markdown entirely; its tools + // surface only in the metadata pane. + expect(newestFirst).not.toContain("read, grep"); + expect(newestFirst).toContain("**Tools used:** edit"); + }); + + test("last assistant text is the newest turn that produced text", () => { + const messages = [ + { id: "m1", role: "user" as const, text: "q", time: 1, tools: [] }, + { id: "m2", role: "assistant" as const, text: "answer", time: 2, tools: [] }, + { id: "m3", role: "assistant" as const, text: "", time: 3, tools: ["edit"] }, + ]; + expect(lastAssistantText(messages)).toBe("answer"); + }); +}); From d95d25d08ced0e80e633c579973c40fd54709284 Mon Sep 17 00:00:00 2001 From: adityazxxx Date: Wed, 23 Sep 2026 07:00:10 +0700 Subject: [PATCH 4/5] fix: tighten loopback check to full four-octet IPv4 addresses --- extensions/opencode/src/lib/opencode/discovery.ts | 4 +++- extensions/opencode/test/discovery.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/extensions/opencode/src/lib/opencode/discovery.ts b/extensions/opencode/src/lib/opencode/discovery.ts index eb26171aa..7a663e019 100644 --- a/extensions/opencode/src/lib/opencode/discovery.ts +++ b/extensions/opencode/src/lib/opencode/discovery.ts @@ -40,7 +40,9 @@ function basicAuthHeaders(username?: string, password?: string): Record { } }); + test("a 127.* hostname that is not a loopback address is rejected", async () => { + // 127.attacker.example resolves outside the machine, so the password + // must not travel over plaintext HTTP. + try { + await resolveEndpoint({ serverUrl: "http://127.attacker.example:4096", serverPassword: "secret" }); + throw new Error("expected resolveEndpoint to reject"); + } catch (error) { + expect((error as Error).message).toBe("Password auth needs HTTPS on remote servers."); + } + }); + test("remote https with a password sends basic auth", async () => { const endpoint = await resolveEndpoint({ serverUrl: "https://opencode.example.com", From 8a14546680230157a266675ead6c1552e72b9546 Mon Sep 17 00:00:00 2001 From: adityazxxx Date: Wed, 23 Sep 2026 09:29:01 +0700 Subject: [PATCH 5/5] fix: address review findings across picker, review, and ask flows --- extensions/opencode/src/ask.tsx | 6 +++- .../src/components/project-picker.tsx | 12 +++++++ .../opencode/src/components/session-list.tsx | 17 +++++++--- .../src/components/session-transcript.tsx | 8 +++-- extensions/opencode/src/lib/launch.ts | 10 ++++++ extensions/opencode/src/review-changes.tsx | 33 ++++++++++++++++--- extensions/opencode/test/config.test.ts | 8 +++++ 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/extensions/opencode/src/ask.tsx b/extensions/opencode/src/ask.tsx index 71a43f82b..ace4aaf66 100644 --- a/extensions/opencode/src/ask.tsx +++ b/extensions/opencode/src/ask.tsx @@ -258,9 +258,13 @@ export function AskView(props: { readonly endpoint: Endpoint; readonly config: C void showToast({ title: "Type a prompt first" }); return; } + if (busy) { + void showToast({ title: "OpenCode is still responding" }); + return; + } void send(text); void clearSearchBar(); - }, [searchText, send]); + }, [searchText, send, busy]); const askActions = ( diff --git a/extensions/opencode/src/components/project-picker.tsx b/extensions/opencode/src/components/project-picker.tsx index 09e11e90c..614cba6d0 100644 --- a/extensions/opencode/src/components/project-picker.tsx +++ b/extensions/opencode/src/components/project-picker.tsx @@ -63,6 +63,18 @@ export function ProjectPickerList(props: { ); } + if (projects !== undefined && sorted.length === 0) { + return ( + + + + ); + } + return ( { + if (directoryFilter !== "all" && !directories.includes(directoryFilter)) { + setDirectoryFilter("all"); + } + }, [directories, directoryFilter]); + if (error) { return setReloadKey((v) => v + 1)} />; } @@ -391,7 +400,7 @@ export function SessionListView(props: { searchBarPlaceholder="Search sessions" navigationTitle={props.navigationTitle} searchBarAccessory={ - directories.length > 1 ? ( + directories.length > 1 || directoryFilter !== "all" ? ( 0 - ? transcriptMarkdown(messages) - : "…"; + : messages === undefined + ? "…" + : messages.length > 0 + ? transcriptMarkdown(messages) + : "**No transcript messages.**"; // Loading feedback: Detail has no isLoading prop, so an animated toast // covers the wait and is hidden once the transcript arrives. diff --git a/extensions/opencode/src/lib/launch.ts b/extensions/opencode/src/lib/launch.ts index f06722bf1..1cca8cb4a 100644 --- a/extensions/opencode/src/lib/launch.ts +++ b/extensions/opencode/src/lib/launch.ts @@ -52,6 +52,16 @@ function posixQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } +/** + * Copyable shell command that resumes a session in the TUI. Every argument is + * quoted, since directory and session ID come from server responses. + */ +export function buildResumeCommand(sessionID: string, directory: string | undefined, configured?: string): string { + const binary = posixQuote(configured || "opencode"); + const cd = directory ? `cd -- ${posixQuote(directory)} && ` : ""; + return `${cd}${binary} --session ${posixQuote(sessionID)}`; +} + /** Open a terminal window whose working directory is `directory`. */ export async function openTerminalAt(directory: string): Promise { const command = `cd -- ${posixQuote(directory)} && exec "$SHELL"`; diff --git a/extensions/opencode/src/review-changes.tsx b/extensions/opencode/src/review-changes.tsx index 5d24b05c9..68919844a 100644 --- a/extensions/opencode/src/review-changes.tsx +++ b/extensions/opencode/src/review-changes.tsx @@ -19,6 +19,8 @@ import { type ReviewScope, } from "./lib/git"; import { buildExplainPrompt, buildReviewPrompt } from "./lib/prompts"; +import { mapOpenCodeError, type OpenCodeError } from "./lib/opencode/errors"; +import { OpenCodeErrorView } from "./components/error-state"; import { ProjectPickerList, basename } from "./components/project-picker"; import { OpenTUIAction, OpenTerminalAction, SentView } from "./components/actions"; @@ -197,6 +199,7 @@ function ReviewScopeStep(props: { }): ReactNode { const { pop, push } = useNavigation(); const [vcs, setVcs] = useState(undefined); + const [vcsError, setVcsError] = useState(null); const [gitOK, setGitOK] = useState(undefined); const [scope, setScope] = useState("working"); const [files, setFiles] = useState([]); @@ -215,15 +218,27 @@ function ReviewScopeStep(props: { void service .vcs(props.project.canonical) .then((info) => { - if (!cancelled) setVcs(info); + if (cancelled) return; + setVcs(info); + setVcsError(null); }) - .catch(() => { - if (!cancelled) setVcs(null); + .catch((caught) => { + if (cancelled) return; + // Not-found means the directory has no version control, a normal + // state. Every other failure (unreachable, auth) keeps the mapped + // error so the view reports the real problem. + const mapped = mapOpenCodeError(caught); + if (mapped.kind === "not-found") { + setVcs(null); + setVcsError(null); + } else { + setVcsError(mapped); + } }); return () => { cancelled = true; }; - }, [props.project.canonical, service]); + }, [props.project.canonical, service, reloadKey]); useEffect(() => { let cancelled = false; @@ -279,6 +294,16 @@ function ReviewScopeStep(props: { ); } + if (vcsError) { + return ( + setReloadKey((v) => v + 1)} + /> + ); + } + if (vcs === null) { return ( diff --git a/extensions/opencode/test/config.test.ts b/extensions/opencode/test/config.test.ts index 5fefd8d6b..8ed508e90 100644 --- a/extensions/opencode/test/config.test.ts +++ b/extensions/opencode/test/config.test.ts @@ -60,6 +60,14 @@ describe("launch", () => { } }); + test("resume command quotes every argument", async () => { + stubs.spawnedCommands = []; + const { buildResumeCommand } = await import("../src/lib/launch"); + const command = buildResumeCommand("ses_x'1", "/tmp/some dir", "/usr/local/bin/opencode"); + expect(command).toContain("cd -- '/tmp/some dir'"); + expect(command).toContain("'/usr/local/bin/opencode' --session 'ses_x'\"'\"'1'"); + }); + test("a terminal opens at the directory with safe quoting", async () => { stubs.spawnedCommands = []; await openTerminalAt("/tmp/some dir with 'quotes'");