Compare commits

..

22 Commits

Author SHA1 Message Date
David Hill
77b3b46788 tui: keep file tree open at its minimum resized width (#18777) 2026-03-23 20:06:43 +08:00
Brendan Allan
36dfe1646b fix(app): only navigate prompt history when input is empty (#18775) 2026-03-23 11:48:34 +00:00
opencode-agent[bot]
6926dc26d1 chore: update nix node_modules hashes 2026-03-23 10:52:56 +00:00
opencode-agent[bot]
eb74e4a6d2 chore: update nix node_modules hashes 2026-03-23 10:37:23 +00:00
opencode-agent[bot]
85d8e143bf chore: generate 2026-03-23 10:35:30 +00:00
Brendan Allan
8e1b53b32c fix(app): handle session busy state better (#18758) 2026-03-23 10:34:32 +00:00
Brendan Allan
0a7dfc03ee fix(app): lift up project hover state to layout (#18732) 2026-03-23 08:58:20 +00:00
Brendan Allan
4c27e7fc64 electron: more robust sidecar kill handling (#18742) 2026-03-23 08:44:23 +00:00
Shoubhit Dash
0f5626d2e4 fix(app): prefer cmd+k for command palette (#18731) 2026-03-23 08:00:24 +00:00
Shoubhit Dash
5ea95451dd fix(app): prevent stale session hover preview on refocus (#18727) 2026-03-23 07:25:30 +00:00
Shoubhit Dash
9239d877b9 fix(app): batch multi-file prompt attachments (#18722) 2026-03-23 06:44:17 +00:00
github-actions[bot]
fc68c24433 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18718#issuecomment-4108322776
2026-03-23 06:28:47 +00:00
Luke Parker
db9619dad6 Add 'write' role to vouch manage action (#18718) 2026-03-23 06:27:35 +00:00
James Long
84d9b38873 fix(core): fix file watcher test (#18698) 2026-03-23 03:35:17 +00:00
opencode-agent[bot]
8035c3435b chore: update nix node_modules hashes 2026-03-23 01:03:20 +00:00
Sebastian
71e7603d71 Upgrade opentui to 0.1.90 (#18551) 2026-03-23 01:45:34 +01:00
David Hill
40e49c5b49 tui: keep patch tool counts visible with long filenames (#18678) 2026-03-23 00:45:11 +00:00
Luke Parker
afe9b97274 fix(app): restore keyboard project switching in open sidebar (#18682) 2026-03-23 00:39:46 +00:00
opencode-agent[bot]
3b3549902d chore: update nix node_modules hashes 2026-03-23 00:29:45 +00:00
David Hill
e9a9c75c1f tweak(ui): fix padding bottom on the context tab (#18680) 2026-03-23 00:23:45 +00:00
David Hill
2b171828b0 tui: prevent project avatar popover flicker when switching projects (#18660)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-23 10:20:49 +10:00
Luke Parker
8dd817023a chore: bump Bun to 1.3.11 (#18144) 2026-03-23 10:19:21 +10:00
43 changed files with 435 additions and 637 deletions

3
.github/VOUCHED.td vendored
View File

@@ -10,6 +10,8 @@
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-danieljoshuanazareth
-danieljoshuanazareth
edemaine
-florianleibert
@@ -23,4 +25,3 @@ r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-danieljoshuanazareth

View File

@@ -33,6 +33,6 @@ jobs:
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

View File

@@ -41,6 +41,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
@@ -129,7 +130,7 @@
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
@@ -337,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.88",
"@opentui/solid": "0.1.88",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -611,7 +612,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.9",
"@types/bun": "1.3.11",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
@@ -1447,21 +1448,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="],
"@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
"@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="],
"@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1889,6 +1890,8 @@
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
@@ -2057,7 +2060,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
@@ -2453,7 +2456,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-u+uZX7mhtm5eywGybB7/MjBMG2xl4Ve9VG33AAFgNno=",
"aarch64-linux": "sha256-pc1Xhd2bkwNohGMtzRnEuS5ZN1qWhJncYhNVAXega1g=",
"aarch64-darwin": "sha256-A5qUpqgm9ZFvWVhn/WdiX4lVs4ihbAclJDvCFAmx5Wg=",
"x86_64-darwin": "sha256-ECLrMGE51AlYJ4JKDtziDKxhyK7WLt8R+8RVFdXH1WU="
"x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
}
}

View File

@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.10",
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -26,7 +26,7 @@
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",

View File

@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) {
export async function openPalette(page: Page, key = "K") {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
await page.keyboard.press(`${modKey}+${key}`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})

View File

@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft)
await wait(page, draft)
await edge(page, "start")
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, second)
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, draft)
await wait(page, "")
})
})

View File

@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
expect(initialKeybind).toContain("K")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)

View File

@@ -1,6 +1,16 @@
import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
import {
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await cleanupSession({ sdk, sessionID: two.id })
}
})
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})

View File

@@ -51,6 +51,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",

View File

@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1388,11 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) {
for (const file of Array.from(list)) {
void addAttachment(file)
}
}
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>

View File

@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
return found
}
const removeAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file")
const files = Array.from(clipboardData.items).flatMap((item) => {
if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
if (fileItems.length > 0) {
let found = false
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
if (files.length > 0) {
await addAttachments(files)
return
}
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
let found = false
for (const file of Array.from(dropped)) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && dropped.length > 0) warn()
await addAttachments(Array.from(dropped))
}
onMount(() => {
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
return {
addAttachment,
addAttachments,
removeAttachment,
handlePaste,
}

View File

@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]

View File

@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)

View File

@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0
if (direction === "up") return position === 0 && text.length === 0
return position === text.length
}

View File

@@ -267,14 +267,14 @@ export function SessionContextTab() {
return (
<ScrollView
class="@container h-full pb-10"
class="@container h-full"
viewportRef={(el) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}

View File

@@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("")
})
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
})

View File

@@ -276,7 +276,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Add file",
"prompt.action.attachFile": "Add files",
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",

View File

@@ -211,13 +211,22 @@ export default function Layout(props: ParentProps) {
onMount(() => {
const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
})
@@ -237,6 +246,12 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined
}
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
@@ -305,8 +320,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return
setState("hoverSession", undefined)
setHoverProject(undefined)
reset()
}
const navigateWithSidebarReset = (href: string) => {
@@ -1975,6 +1989,10 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject,
openSidebar: () => layout.sidebar.open(),
closeProject,

View File

@@ -157,34 +157,45 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={props.trigger}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
}): JSX.Element => {
let ref: HTMLDivElement | undefined
return (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={<div ref={ref}>{props.trigger}</div>}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()

View File

@@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void
openSidebar: () => void
closeProject: (directory: string) => void
@@ -109,8 +110,14 @@ const ProjectTile = (props: {
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onPointerDown={(event) => {
if (event.button === 0 && !event.ctrlKey) {
props.setOpen(false)
props.setSuppressHover(true)
return
}
if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setOpen(false)
props.setSuppressHover(true)
event.preventDefault()
}}
@@ -130,12 +137,11 @@ const ProjectTile = (props: {
props.onProjectFocus(props.project.worktree)
}}
onClick={() => {
props.setOpen(false)
if (props.selected()) {
props.setSuppressHover(true)
layout.sidebar.toggle()
return
}
props.setSuppressHover(false)
props.navigateToProject(props.project.worktree)
}}
onBlur={() => props.setOpen(false)}
@@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
setOpen: (value: boolean) => void
ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
@@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
props.ctx.openSidebar()
props.setOpen(false)
props.ctx.onHoverOpenChanged(props.project.worktree, false)
if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree)
}}
@@ -284,28 +289,16 @@ export const SortableProject = (props: {
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({
open: false,
menu: false,
suppressHover: false,
})
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo(
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
)
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
createEffect(() => {
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
})
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
@@ -346,7 +339,7 @@ export const SortableProject = (props: {
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)}
setOpen={(value) => setState("open", value)}
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setSuppressHover={(value) => setState("suppressHover", value)}
language={language}
/>
@@ -357,7 +350,7 @@ export const SortableProject = (props: {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}>
<HoverCard
open={!state.suppressHover && state.open && !state.menu}
open={!state.suppressHover && hoverOpen() && !state.menu}
openDelay={0}
closeDelay={0}
placement="right-start"
@@ -366,7 +359,7 @@ export const SortableProject = (props: {
onOpenChange={(value) => {
if (state.menu) return
if (value && state.suppressHover) return
setState("open", value)
props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined)
}}
>
@@ -381,7 +374,6 @@ export const SortableProject = (props: {
projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
ctx={props.ctx}
language={language}
/>

View File

@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
@@ -30,6 +30,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
type MessageComment = {
path: string
@@ -250,38 +251,21 @@ export function MessageTimeline(props: {
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({
open: false,
show: false,
fade: false,
const [timeoutDone, setTimeoutDone] = createSignal(true)
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
if (working()) return "showing"
if (prev === "showing" || !timeoutDone()) return "hiding"
return "hidden"
})
let f: number | undefined
const clear = () => {
if (f !== undefined) window.clearTimeout(f)
f = undefined
}
createEffect(() => {
if (workingStatus() !== "hiding") return
setTimeoutDone(false)
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
})
onCleanup(clear)
createEffect(
on(
working,
(on, prev) => {
clear()
if (on) {
setSlot({ open: true, show: true, fade: false })
return
}
if (prev) {
setSlot({ open: false, show: true, fade: true })
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
return
}
setSlot({ open: false, show: false, fade: false })
},
{ defer: true },
),
)
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
@@ -676,17 +660,15 @@ export function MessageTimeline(props: {
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
width: working() ? "16px" : "0px",
"margin-right": working() ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={slot.show}>
<Show when={workingStatus() !== "hidden"}>
<div
class="transition-opacity duration-200 ease-out"
classList={{
"opacity-0": slot.fade,
}}
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div>
@@ -912,7 +894,6 @@ export function MessageTimeline(props: {
</div>
</div>
</Show>
<div
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"

View File

@@ -438,12 +438,10 @@ export function SessionSidePanel(props: {
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>

View File

@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}),

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@opencode-ai/sdk/v2"
import { extractPromptFromParts } from "./prompt"
describe("extractPromptFromParts", () => {
test("restores multiple uploaded attachments", () => {
const parts = [
{
id: "text_1",
type: "text",
text: "check these",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_1",
type: "file",
mime: "image/png",
url: "data:image/png;base64,AAA",
filename: "a.png",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_2",
type: "file",
mime: "application/pdf",
url: "data:application/pdf;base64,BBB",
filename: "b.pdf",
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]
const result = extractPromptFromParts(parts)
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
expect(result.slice(1)).toMatchObject([
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
])
})
})

View File

@@ -42,7 +42,7 @@
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"drizzle-kit": "catalog:",
"mysql2": "3.14.4",

View File

@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.5
ARG BUN_VERSION=1.3.11
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

View File

@@ -35,6 +35,7 @@ export type CommandEvent =
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
@@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
treeKill(child.pid)
}
return { events, child: { kill }, exit }
return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {

View File

@@ -81,6 +81,17 @@ function setupApp() {
killSidecar()
})
app.on("will-quit", () => {
killSidecar()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
app.exit(0)
})
}
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
@@ -234,8 +245,15 @@ registerIpcHandlers({
function killSidecar() {
if (!sidecar) return
const pid = sidecar.pid
sidecar.kill()
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
}
function ensureLoopbackNoProxy() {

View File

@@ -101,8 +101,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.88",
"@opentui/solid": "0.1.88",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -173,6 +173,6 @@ Still open and likely worth migrating:
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
- [x] `Project`
- [ ] `Project`
- [ ] `LSP`
- [ ] `MCP`

View File

@@ -428,7 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromInts(0, 0, 0, 0)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
const isDark = mode == "dark"
const col = (i: number) => {

View File

@@ -1465,6 +1465,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
streaming={true}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
</Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>

View File

@@ -6,6 +6,7 @@ import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
@@ -14,10 +15,6 @@ import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Effect, FileSystem, Layer, Path, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -363,40 +360,40 @@ export namespace Project {
return (await fromDirectory(input.directory)).project
}
export const UpdateInput = z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
export async function update(input: UpdateInput) {
const id = ProjectID.make(input.projectID)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
}
export const update = fn(
z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
}),
async (input) => {
const id = ProjectID.make(input.projectID)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
},
)
export async function sandboxes(id: ProjectID) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
@@ -456,359 +453,4 @@ export namespace Project {
})
return data
}
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
type GitResult = { code: number; text: string; stderr: string }
export const layer: Layer.Layer<
Service,
never,
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* FileSystem.FileSystem
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true }))
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
function emitUpdated(data: Info) {
GlobalBus.emit("event", {
payload: { type: Event.Updated.type, properties: data },
})
}
return Service.of({
fromDirectory: Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
const resolveGitPath = (cwd: string, name: string) => {
if (!name) return cwd
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = Filesystem.windowsPath(name)
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
const content = yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.succeed(undefined)),
)
return content
})
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = yield* Effect.promise(() => matches.next().then((x) => x.value))
yield* Effect.promise(() => matches.return())
if (!dotgit) {
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
let sandbox = pathSvc.dirname(dotgit)
const gitBinary = which("git")
let id = yield* readCachedProjectId(dotgit)
if (!gitBinary) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
const worktree = commonDir.code === 0
? (() => {
const common = resolveGitPath(sandbox, commonDir.text.trim())
return common === sandbox ? sandbox : pathSvc.dirname(common)
})()
: undefined
if (!worktree) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
if (id == null) {
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.code === 0
? revList.text.split("\n").filter(Boolean).map((x) => x.trim()).toSorted()
: undefined
if (!roots) {
return {
id: ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code === 0) {
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
} else {
return {
id,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
return { id, sandbox, worktree, vcs: "git" as const }
})
// Phase 2: upsert
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree,
vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
worktree: data.worktree,
vcs: data.vcs,
time: { ...existing.time, updated: Date.now() },
}
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) => fsys.exists(s).pipe(Effect.orDie, Effect.map((exists) => (exists ? s : undefined))),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d.insert(ProjectTable).values({
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
}).onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
},
}).run(),
)
if (data.id !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
}
emitUpdated(result)
return { project: result, sandbox: data.sandbox }
}),
discover: Effect.fn("Project.discover")(function* (input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const matches = yield* Effect.promise(() =>
Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
}),
)
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
const base64 = Buffer.from(buffer).toString("base64")
const mime = Filesystem.mimeType(shortest) || "image/png"
const url = `data:${mime};base64,${base64}`
yield* Effect.promise(() => update({ projectID: input.id, icon: { url } }))
}),
list: Effect.fn("Project.list")(function* () {
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
}),
get: Effect.fn("Project.get")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
return row ? fromRow(row) : undefined
}),
update: Effect.fn("Project.update")(function* (input: UpdateInput) {
const id = ProjectID.make(input.projectID)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
emitUpdated(data)
return data
}),
initGit: Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
if (input.project.vcs === "git") return input.project
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
if (result.code !== 0) {
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
}
const { project } = yield* Effect.promise(() => fromDirectory(input.directory))
return project
}),
setInitialized: Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
}),
sandboxes: Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
const valid: string[] = []
for (const dir of data.sandboxes) {
if (yield* fsys.exists(dir).pipe(Effect.orDie)) valid.push(dir)
}
return valid
}),
addSandbox: Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d.update(ProjectTable).set({ sandboxes: sboxes, time_updated: Date.now() }).where(eq(ProjectTable.id, id)).returning().get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
emitUpdated(fromRow(result))
}),
removeSandbox: Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d.update(ProjectTable).set({ sandboxes: sboxes, time_updated: Date.now() }).where(eq(ProjectTable.id, id)).returning().get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
emitUpdated(fromRow(result))
}),
})
}),
)
const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
// Note: list, get, setInitialized remain as direct sync functions (callers rely on sync access).
// The Effect service wraps them for Effect-native consumers.
}

View File

@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.UpdateInput.omit({ projectID: true })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")

View File

@@ -177,13 +177,17 @@ describeWatcher("FileWatcher", () => {
await withWatcher(tmp.path, Effect.void)
// Now write a file — no watcher should be listening
await Effect.runPromise(
noUpdate(
tmp.path,
(e) => e.file === file,
Effect.promise(() => fs.writeFile(file, "gone")),
),
)
await Instance.provide({
directory: tmp.path,
fn: () =>
Effect.runPromise(
noUpdate(
tmp.path,
(e) => e.file === file,
Effect.promise(() => fs.writeFile(file, "gone")),
),
),
})
})
test("ignores .git/index changes", async () => {

View File

@@ -393,75 +393,3 @@ describe("Project.update", () => {
expect(updated.commands?.start).toBe("make start")
})
})
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(Filesystem.resolve(tmp.path))
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
})
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(Filesystem.resolve(tmp.path))
const found = Project.get(project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
})
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
expect(found).toBeUndefined()
})
})
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(Filesystem.resolve(tmp.path))
expect(project.time.initialized).toBeUndefined()
Project.setInitialized(project.id)
const updated = Project.get(project.id)
expect(updated?.time.initialized).toBeDefined()
})
})
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(Filesystem.resolve(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir)
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir)
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
})
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(Filesystem.resolve(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir)
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})

View File

@@ -8,7 +8,7 @@ const dict: Record<string, string> = {
"prompt.placeholder.shell": "Run a shell command...",
"prompt.placeholder.summarizeComment": "Summarize this comment",
"prompt.placeholder.summarizeComments": "Summarize these comments",
"prompt.action.attachFile": "Attach file",
"prompt.action.attachFile": "Attach files",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.attachment.remove": "Remove attachment",

View File

@@ -8,7 +8,10 @@
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
flex: 0 1 auto;
width: auto;
max-width: calc(100% - 24px);
min-width: 0;
display: flex;
align-items: center;
align-self: stretch;
@@ -51,12 +54,16 @@
[data-slot="basic-tool-tool-info"] {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
flex: 0 1 auto;
width: auto;
display: flex;
max-width: 100%;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
@@ -151,4 +158,10 @@
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
}
[data-slot="basic-tool-tool-action"] {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
}

View File

@@ -174,7 +174,9 @@ export function BasicTool(props: BasicToolProps) {
</Show>
</Show>
</div>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
<Show when={!pending() && trigger().action}>
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
</Show>
</div>
)}
</Match>

View File

@@ -13,7 +13,7 @@ export function HoverCard(props: HoverCardProps) {
return (
<Kobalte gutter={4} {...rest}>
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
<Kobalte.Trigger as="div" data-slot="hover-card-trigger" tabIndex={-1}>
{local.trigger}
</Kobalte.Trigger>
<Kobalte.Portal mount={local.mount}>

View File

@@ -424,7 +424,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 12px;
width: 100%;
[data-slot="message-part-title-area"] {
@@ -436,10 +436,11 @@
}
[data-slot="message-part-title"] {
flex-shrink: 0;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
@@ -466,12 +467,17 @@
}
[data-slot="message-part-title-text"] {
flex-shrink: 0;
text-transform: capitalize;
color: var(--text-strong);
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-regular);
}
@@ -501,6 +507,7 @@
gap: 16px;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
}
@@ -1183,6 +1190,7 @@
display: flex;
flex-grow: 1;
min-width: 0;
overflow: hidden;
}
[data-slot="apply-patch-directory"] {
@@ -1196,7 +1204,11 @@
[data-slot="apply-patch-filename"] {
color: var(--text-strong);
flex-shrink: 0;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="apply-patch-trigger-actions"] {