Compare commits

...

57 Commits

Author SHA1 Message Date
Adam
3347b591ce fix(app): better memory management of sessions 2026-02-12 19:12:35 -06:00
opencode
5bbc571276 release: v1.1.64 2026-02-12 18:11:28 -06:00
Ariane Emory
5cc461578b fix: token substitution in OPENCODE_CONFIG_CONTENT (#13384) 2026-02-12 18:11:27 -06:00
Aiden Cline
402cf49164 chore: cleanup flag code (#13389) 2026-02-12 18:11:27 -06:00
opencode-agent[bot]
afe9763668 chore: generate 2026-02-12 18:11:27 -06:00
Smit Chaudhary
e22426303a fix: look for recent model in fallback in cli (#12582) 2026-02-12 18:11:27 -06:00
opencode-agent[bot]
f148ec687e chore: update nix node_modules hashes 2026-02-12 18:11:26 -06:00
Luke Parker
27aa01d1b7 fix: baseline CPU detection (#13371) 2026-02-12 18:11:26 -06:00
Luke Parker
fe4f2b1b23 feat: windows selection behavior, manual ctrl+c (#13315) 2026-02-12 18:11:26 -06:00
Sebastian
14b65ee985 do not open console on error (#13374) 2026-02-12 18:11:26 -06:00
opencode-agent[bot]
e1b90d0340 chore: generate 2026-02-12 18:11:26 -06:00
Aman Kalra
559b2275c0 docs: update STACKIT provider documentation with typo fix (#13357)
Co-authored-by: amankalra172 <aman.kalra@st.ovgu.de>
2026-02-12 18:11:25 -06:00
Adam
20545c98cc fix(app): terminal pty isolation 2026-02-12 18:11:25 -06:00
Adam
2d13bda31a feat(app): option to turn off sound effects 2026-02-12 18:11:25 -06:00
Adam
79ee589099 fix(app): normalize oauth error messages 2026-02-12 18:11:25 -06:00
Adam
8d53d22c36 chore: cleanup 2026-02-12 18:11:25 -06:00
Adam
ddc3032b72 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
402fc9eed9 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
f554e5ee7a chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
a21a441409 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
c6500328ca fix(app): suggestion active state broken 2026-02-12 18:11:24 -06:00
Adam
ff1077b3ad fix(app): remote http server connections 2026-02-12 18:11:23 -06:00
Aiden Cline
90e248f6a0 test: add more test cases for project.test.ts (#13355) 2026-02-12 18:11:23 -06:00
opencode
61455e7d31 release: v1.1.63 2026-02-12 18:11:23 -06:00
Dax Raad
dcce83aaa0 improve codex model list 2026-02-12 18:11:23 -06:00
opencode
79fc63709c release: v1.1.62 2026-02-12 18:11:23 -06:00
Adam
6532b4fb76 fix(app): project icons unloading 2026-02-12 18:11:22 -06:00
Adam
0e7627f637 wip(app): timeline changes 2026-02-12 15:46:08 -06:00
Adam
12a80c4000 wip(app): timeline changes 2026-02-12 15:44:02 -06:00
Adam
713cc7339e wip(app): timeline changes 2026-02-12 15:35:09 -06:00
Adam
4da246ea01 wip(app): timeline changes 2026-02-12 15:19:54 -06:00
Adam
cbf9641642 wip(app): timeline changes 2026-02-12 15:07:55 -06:00
Adam
8e69ff0fe7 wip(app): timeline changes 2026-02-12 14:59:04 -06:00
David Hill
490967208c tweak(ui): show spinners after titles and lock tools until done 2026-02-12 11:38:05 -06:00
David Hill
2fc3bfefc0 fix(ui): improve bash output scrolling and wrapping 2026-02-12 11:38:05 -06:00
David Hill
f10787ef74 tweak(ui): remove horizontal padding from todos and question answers 2026-02-12 11:38:05 -06:00
David Hill
bda07f7d8f refactor(ui): rework bash output container and copy 2026-02-12 11:38:05 -06:00
David Hill
dec46fba39 tweak(ui): expand user message hover area 2026-02-12 11:38:05 -06:00
David Hill
383a0fb896 tweak(ui): add markdown copy tooltip and restyle button 2026-02-12 11:38:04 -06:00
David Hill
532d7e9d80 tweak(ui): prevent copy button layout shift 2026-02-12 11:38:04 -06:00
David Hill
881634f2e7 tweak(ui): adjust tool output spacing and title weight 2026-02-12 11:38:04 -06:00
David Hill
99e7521289 tweak(ui): remove context tool icons and add tool spacing 2026-02-12 11:38:04 -06:00
David Hill
5a663fbd23 tweak(ui): unify tool header typography 2026-02-12 11:38:04 -06:00
David Hill
1760e4fb6e tweak(ui): style edit/write tool content container 2026-02-12 11:38:03 -06:00
David Hill
c0eb553a94 tweak(ui): tighten response copy tooltip gutter 2026-02-12 11:38:03 -06:00
David Hill
c807319f31 tweak(ui): strengthen user message text 2026-02-12 11:38:03 -06:00
David Hill
f16466c996 tweak(ui): refine tool typography and truncation 2026-02-12 11:38:03 -06:00
David Hill
bc501167b2 fix(ui): allow explore agent output to fully expand 2026-02-12 11:38:03 -06:00
David Hill
536d3f73af tweak(ui): tighten chevron spacing 2026-02-12 11:38:02 -06:00
David Hill
594341d8f8 tweak(ui): style agent title and link 2026-02-12 11:38:02 -06:00
David Hill
3ba0265ad8 tweak(ui): flatten tool and collapsible UI 2026-02-12 11:38:02 -06:00
David Hill
e448e77c90 tweak(ui): refine copy tooltip text and spacing 2026-02-12 11:38:02 -06:00
David Hill
1c80f92281 tweak(ui): adjust user message bubble styling 2026-02-12 11:38:01 -06:00
Adam
b10885b557 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
c31e678391 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
d238344931 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
a74d6c3c23 wip(app): timeline changes 2026-02-12 11:38:00 -06:00
84 changed files with 2183 additions and 1817 deletions

View File

@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.61",
"version": "1.1.64",
"bin": {
"opencode": "./bin/opencode",
},
@@ -366,7 +366,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -386,7 +386,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.61",
"version": "1.1.64",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -397,7 +397,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -410,7 +410,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -452,7 +452,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"zod": "catalog:",
},
@@ -463,7 +463,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -522,7 +522,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.5",
"@types/bun": "1.3.9",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
@@ -1853,7 +1853,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -2181,7 +2181,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],

16
install
View File

@@ -130,7 +130,7 @@ else
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
@@ -141,6 +141,20 @@ else
needs_baseline=true
fi
fi
if [ "$os" = "windows" ]; then
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
out=""
if command -v powershell.exe >/dev/null 2>&1; then
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
elif command -v pwsh >/dev/null 2>&1; then
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
fi
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-saYZlUTkBfg9vp5J1CrJUM1PBXK4xKwyz28RKlT0JWo=",
"aarch64-linux": "sha256-qoiX2CpOD+HSI+eLh3I84TTPdhWdG6MzfkDAXE6ldPo=",
"aarch64-darwin": "sha256-LbAvdaOBuftBoHvQPFwJGr0smg8vH4wNHS6BYdyXdDs=",
"x86_64-darwin": "sha256-bv5qb9Fi8SyrgZFhcdlvYNc4bjyvdyHY3YgUpmkEH2U="
"x86_64-linux": "sha256-XIf7b6yALzH1/MkGGrsmq2DeXIC9vgD9a7D/dxhi6iU=",
"aarch64-linux": "sha256-mKDCs6QhIelWc3E17zOufaSDTovtjO/Xyh3JtlWl01s=",
"aarch64-darwin": "sha256-wC7bbbIyZ62uMxTr9FElTbEBMrfz0S/ndqwZZ3V9EOA=",
"x86_64-darwin": "sha256-/7Nn65m5Zhvzz0TKsG9nWd2v5WDHQNi3UzCfuAR8SLo="
}
}

View File

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

View File

@@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'

View File

@@ -9,6 +9,7 @@ import {
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsAgentEnabledSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
@@ -335,6 +336,30 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(switchContainer).toBeVisible()
await expect(trigger).toBeEnabled()
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
await expect(trigger).toBeDisabled()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.61",
"version": "1.1.64",
"description": "",
"type": "module",
"exports": {

View File

@@ -103,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
return value.label ?? ""
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
@@ -141,7 +159,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
.catch((e) => {
if (!alive.value) return
dispatch({ type: "auth.error", error: String(e) })
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
}
}
@@ -328,8 +346,7 @@ export function DialogConnectProvider(props: { provider: string }) {
await complete()
return
}
const message = result.error instanceof Error ? result.error.message : String(result.error)
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
}
return (
@@ -385,7 +402,7 @@ export function DialogConnectProvider(props: { provider: string }) {
if (!alive.value) return
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
return
}

View File

@@ -347,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
tabs().open(value)
file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {

View File

@@ -158,14 +158,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
tabs().open(tab)

View File

@@ -53,18 +53,15 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
>
<For each={props.atFlat.slice(0, 10)}>
{(item) => {
const active = props.atActive === props.atKey(item)
const shared = {
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": active,
}
const key = props.atKey(item)
if (item.type === "agent") {
return (
<button
classList={shared}
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
onMouseEnter={() => props.setAtActive(key)}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
@@ -78,9 +75,10 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return (
<button
classList={shared}
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
onMouseEnter={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">

View File

@@ -19,8 +19,7 @@ function openSessionContext(args: {
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
args.layout.fileTree.open()
args.layout.fileTree.setTab("all")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
args.tabs.open("context")
args.tabs.setActive("context")
}

View File

@@ -0,0 +1,85 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
function color(status: string) {
if (status === "completed") return "var(--icon-success-base)"
if (status === "in_progress") return "var(--icon-info-base)"
if (status === "cancelled") return "var(--icon-critical-base)"
return "var(--icon-weaker)"
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
const [store, setStore] = createStore({
collapsed: false,
})
const progress = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed}/${total}`
})
const preview = createMemo(() => {
const active =
props.todos.find((todo) => todo.status === "in_progress") ??
props.todos.find((todo) => todo.status === "pending") ??
props.todos[0]
if (!active) return ""
return active.content
})
return (
<div class="mb-3 rounded-md border border-border-weak-base bg-surface-raised-stronger-non-alpha shadow-xs-border">
<div class="px-3 py-2 flex items-center gap-2">
<span class="text-12-medium text-text-strong">{props.title}</span>
<Show when={progress()}>
<span class="text-12-regular text-text-weak">{progress()}</span>
</Show>
<div class="ml-auto">
<IconButton
icon="chevron-down"
size="small"
variant="ghost"
classList={{ "rotate-180": !store.collapsed }}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setStore("collapsed", (value) => !value)}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<Show when={store.collapsed} fallback={<TodoList todos={props.todos} />}>
<div class="px-3 pb-3 text-12-regular text-text-base truncate">{preview()}</div>
</Show>
</div>
)
}
function TodoList(props: { todos: Todo[] }) {
return (
<div class="px-3 pb-3 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.todos}>
{(todo) => (
<div class="flex items-start gap-2 min-w-0">
<span style={{ color: color(todo.status) }} class="text-12-medium leading-5 shrink-0">
</span>
<span
class="text-12-regular min-w-0 break-words"
style={{
color: todo.status === "completed" || todo.status === "cancelled" ? "var(--text-weak)" : undefined,
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</span>
</div>
)}
</For>
</div>
)
}

View File

@@ -311,12 +311,14 @@ export function SessionHeader() {
platform,
})
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const leftMount = createMemo(
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
)
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<>
<Show when={centerMount()}>
<Show when={leftMount()}>
{(mount) => (
<Portal mount={mount()}>
<button
@@ -550,7 +552,7 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
<div class="hidden lg:flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
@@ -583,7 +585,7 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<div class="hidden lg:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
@@ -613,7 +615,7 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<div class="hidden lg:block shrink-0">
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}

View File

@@ -306,39 +306,66 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-agent-enabled">
<Switch
checked={settings.sounds.agentEnabled()}
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.agentEnabled()}
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-permissions-enabled">
<Switch
checked={settings.sounds.permissionsEnabled()}
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.permissionsEnabled()}
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-errors-enabled">
<Switch
checked={settings.sounds.errorsEnabled()}
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.errorsEnabled()}
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
</div>
</SettingsRow>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -160,6 +161,7 @@ export const Terminal = (props: TerminalProps) => {
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
let output: ReturnType<typeof terminalWriter> | undefined
const cleanup = () => {
if (!cleanups.length) return
@@ -300,7 +302,7 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false,
convertEol: true,
convertEol: false,
theme: terminalColors(),
scrollback: 10_000,
ghostty: g,
@@ -312,6 +314,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
output = terminalWriter((data) => t.write(data))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -416,7 +419,7 @@ export const Terminal = (props: TerminalProps) => {
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
t.write(data)
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
@@ -459,6 +462,7 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
})

View File

@@ -315,8 +315,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
const modified = event.ctrlKey || event.metaKey || event.altKey
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
if (isPalette) {
event.preventDefault()

View File

@@ -12,19 +12,32 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (typeof window === "undefined") return
const password = window.__OPENCODE__?.serverPassword
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => {
if (!platform.fetch) return
try {
const url = new URL(server.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
headers: auth,
fetch: eventFetch,
headers: eventFetch ? undefined : auth,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
@@ -80,7 +93,17 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
let streamErrorLogged = false
void (async () => {
const events = await eventSdk.global.event()
const events = await eventSdk.global.event({
onSseError: (error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
for await (const event of events.stream) {
const directory = event.directory ?? "global"
@@ -106,7 +129,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
.catch((error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream failed", error)
console.error("[global-sdk] event stream failed", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
})
onCleanup(() => {

View File

@@ -4,6 +4,7 @@ import {
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -41,6 +42,9 @@ type GlobalStore = {
error?: InitError
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -87,12 +91,27 @@ function createGlobalSync() {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
"session_todo",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
@@ -283,6 +302,7 @@ function createGlobalSync() {
store,
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
sdkFor(directory)
@@ -353,6 +373,9 @@ function createGlobalSync() {
bootstrap,
updateConfig,
project: projectApi,
todo: {
set: setSessionTodo,
},
}
}

View File

@@ -6,6 +6,7 @@ import {
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
@@ -20,6 +21,9 @@ type GlobalStore = {
ready: boolean
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config

View File

@@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
})
}
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
@@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
@@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) {
const event = input.event
switch (event.type) {
@@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {

View File

@@ -233,7 +233,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (!session) return
if (session.parentID) return
playSound(soundSrc(settings.sounds.agent()))
if (settings.sounds.agentEnabled()) {
playSound(soundSrc(settings.sounds.agent()))
}
append({
directory,
@@ -260,7 +262,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (meta.disposed) return
if (session?.parentID) return
playSound(soundSrc(settings.sounds.errors()))
if (settings.sounds.errorsEnabled()) {
playSound(soundSrc(settings.sounds.errors()))
}
const error = "error" in event.properties ? event.properties.error : undefined
append({

View File

@@ -10,8 +10,11 @@ export interface NotificationSettings {
}
export interface SoundSettings {
agentEnabled: boolean
agent: string
permissionsEnabled: boolean
permissions: string
errorsEnabled: boolean
errors: string
}
@@ -57,8 +60,11 @@ const defaultSettings: Settings = {
errors: false,
},
sounds: {
agentEnabled: true,
agent: "staplebops-01",
permissionsEnabled: true,
permissions: "staplebops-02",
errorsEnabled: true,
errors: "nope-03",
},
}
@@ -168,14 +174,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
},
sounds: {
agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
setAgentEnabled(value: boolean) {
setStore("sounds", "agentEnabled", value)
},
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
permissionsEnabled: withFallback(
() => store.sounds?.permissionsEnabled,
defaultSettings.sounds.permissionsEnabled,
),
setPermissionsEnabled(value: boolean) {
setStore("sounds", "permissionsEnabled", value)
},
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
setErrorsEnabled(value: boolean) {
setStore("sounds", "errorsEnabled", value)
},
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)

View File

@@ -106,6 +106,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 400
const trimPageSize = 80
const fullSessionLimit = 5
const full = new Map<string, true>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -115,6 +118,112 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
loading: {} as Record<string, boolean>,
})
const touch = (key: string) => {
if (full.has(key)) full.delete(key)
full.set(key, true)
while (full.size > fullSessionLimit) {
const oldest = full.keys().next().value as string | undefined
if (!oldest) return
full.delete(oldest)
}
}
const evict = (input: { directory: string; store: Child[0]; setStore: Setter; keep?: string }) => {
const keep = new Set<string>()
if (input.keep) keep.add(input.keep)
for (const session of input.store.session) {
if (session?.id) keep.add(session.id)
}
const warm = new Set<string>()
for (const sessionID of keep) {
if (full.has(keyFor(input.directory, sessionID))) warm.add(sessionID)
}
if (input.keep) warm.add(input.keep)
const drop = new Set<string>()
const trim = new Set<string>()
for (const sessionID of Object.keys(input.store.message)) {
if (!keep.has(sessionID)) {
drop.add(sessionID)
continue
}
if (!warm.has(sessionID)) trim.add(sessionID)
}
for (const sessionID of Object.keys(input.store.session_diff)) {
if (!keep.has(sessionID) || !warm.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.todo)) {
if (!keep.has(sessionID) || !warm.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.permission)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.question)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.session_status)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
if (drop.size === 0 && trim.size === 0) return
input.setStore(
produce((draft) => {
for (const sessionID of drop) {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
full.delete(keyFor(input.directory, sessionID))
}
for (const sessionID of trim) {
const messages = draft.message[sessionID]
if (!messages) continue
const count = messages.length - trimPageSize
if (count <= 0) continue
for (const message of messages.slice(0, count)) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
draft.message[sessionID] = messages.slice(count)
}
}),
)
setMeta(
produce((draft) => {
for (const sessionID of drop) {
const key = keyFor(input.directory, sessionID)
delete draft.limit[key]
delete draft.complete[key]
delete draft.loading[key]
}
for (const sessionID of trim) {
const key = keyFor(input.directory, sessionID)
if (draft.limit[key] !== undefined && draft.limit[key] > trimPageSize) {
draft.limit[key] = trimPageSize
}
if (draft.complete[key] !== undefined) {
draft.complete[key] = false
}
}
}),
)
}
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -236,10 +345,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const hasMessages = store.message[sessionID] !== undefined
const hydrated = meta.limit[key] !== undefined
if (hasSession && hasMessages && hydrated) return
if (hasSession && hasMessages && hydrated && full.has(key)) {
touch(key)
evict({ directory, store, setStore, keep: sessionID })
return
}
const count = store.message[sessionID]?.length ?? 0
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
const limit = hydrated ? Math.max(meta.limit[key] ?? messagePageSize, messagePageSize) : limitFor(count)
const sessionReq = hasSession
? Promise.resolve()
@@ -260,7 +373,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const messagesReq =
hasMessages && hydrated
hasMessages && hydrated && full.has(key)
? Promise.resolve()
: loadMessages({
directory,
@@ -270,7 +383,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
limit,
})
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
return runInflight(inflight, key, () =>
Promise.all([sessionReq, messagesReq]).then(() => {
touch(key)
evict({ directory, store, setStore, keep: sessionID })
}),
)
},
async diff(sessionID: string) {
const directory = sdk.directory
@@ -289,12 +407,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.todo[sessionID] !== undefined) return
const existing = store.todo[sessionID]
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
return
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},

View File

@@ -503,6 +503,9 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest",
"session.context.addToContext": "Add {{selection}} to context",
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",

View File

@@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@@ -388,7 +388,9 @@ export default function Layout(props: ParentProps) {
alertedAtBySession.set(sessionKey, now)
if (e.details.type === "permission.asked") {
playSound(soundSrc(settings.sounds.permissions()))
if (settings.sounds.permissionsEnabled()) {
playSound(soundSrc(settings.sounds.permissions()))
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
}

View File

@@ -295,7 +295,7 @@ export const SortableProject = (props: {
const [data] = globalSync.child(directory, { bootstrap: false })
return childMapByParent(data.session)
}
const trigger = (
const tile = () => (
<ProjectTile
project={props.project}
mobile={props.mobile}
@@ -321,14 +321,14 @@ export const SortableProject = (props: {
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview()} fallback={trigger}>
<Show when={preview()} fallback={tile()}>
<HoverCard
open={open() && !menu()}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={trigger}
trigger={tile()}
onOpenChange={(value) => {
if (menu()) return
setOpen(value)

View File

@@ -20,6 +20,7 @@ import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
@@ -91,6 +92,7 @@ export default function Page() {
const local = useLocal()
const file = useFile()
const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
@@ -232,7 +234,7 @@ export default function Page() {
})
}
const isDesktop = createMediaQuery("(min-width: 768px)")
const isDesktop = createMediaQuery("(min-width: 1024px)")
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -556,7 +558,6 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
@@ -675,7 +676,8 @@ export default function Page() {
sdk.directory
const id = params.id
if (!id) return
sync.session.sync(id)
void sync.session.sync(id)
void sync.session.todo(id)
})
createEffect(() => {
@@ -728,13 +730,17 @@ export default function Page() {
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect(
on(
sessionKey,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
setStore("changes", "session")
setUi("autoCreated", false)
},
@@ -753,12 +759,6 @@ export default function Page() {
),
)
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
@@ -931,10 +931,8 @@ export default function Page() {
status,
userMessages,
visibleUserMessages,
activeMessage,
showAllFiles,
navigateMessageByOffset,
setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage,
addSelectionToContext,
focusInput,
@@ -1551,7 +1549,13 @@ export default function Page() {
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<div
class="flex-1 min-h-0 flex"
classList={{
"flex-col": !isDesktop(),
"flex-row": isDesktop(),
}}
>
<SessionMobileTabs
open={!isDesktop() && !!params.id}
mobileTab={store.mobileTab}
@@ -1648,8 +1652,6 @@ export default function Page() {
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
expanded={store.expanded}
onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
/>
</Show>
</Match>
@@ -1680,6 +1682,7 @@ export default function Page() {
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}

View File

@@ -88,8 +88,6 @@ export function MessageTimeline(props: {
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
expanded: Record<string, boolean>
onToggleExpanded: (id: string) => void
}) {
let touchGesture: number | undefined
@@ -164,8 +162,9 @@ export function MessageTimeline(props: {
<Show when={props.showHeader}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
@@ -316,8 +315,6 @@ export function MessageTimeline(props: {
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
stepsExpanded={props.expanded[message.id] ?? false}
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",

View File

@@ -14,12 +14,17 @@ export function SessionMobileTabs(props: {
<Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto">
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: "w-full" }}
onClick={props.onSession}
>
{props.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="w-1/2 !border-r-0"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={props.onChanges}
>

View File

@@ -1,9 +1,10 @@
import { For, Show } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
export function SessionPromptDock(props: {
@@ -11,6 +12,7 @@ export function SessionPromptDock(props: {
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
@@ -122,6 +124,14 @@ export function SessionPromptDock(props: {
</div>
}
>
<Show when={props.todos.length > 0}>
<SessionTodoDock
todos={props.todos}
title={props.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")}
/>
</Show>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}

View File

@@ -42,10 +42,8 @@ export type SessionCommandContext = {
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
activeMessage: () => UserMessage | undefined
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
@@ -168,19 +166,6 @@ export const useSessionCommands = (input: SessionCommandContext) => {
input.view().terminal.open()
},
}),
viewCommand({
id: "steps.toggle",
title: input.language.t("command.steps.toggle"),
description: input.language.t("command.steps.toggle.description"),
keybind: "mod+e",
slash: "steps",
disabled: !input.params.id,
onSelect: () => {
const msg = input.activeMessage()
if (!msg) return
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
},
}),
])
const messageCommands = createMemo(() => [

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { terminalWriter } from "./terminal-writer"
describe("terminalWriter", () => {
test("buffers and flushes once per schedule", () => {
const calls: string[] = []
const scheduled: VoidFunction[] = []
const writer = terminalWriter(
(data) => calls.push(data),
(flush) => scheduled.push(flush),
)
writer.push("a")
writer.push("b")
writer.push("c")
expect(calls).toEqual([])
expect(scheduled).toHaveLength(1)
scheduled[0]?.()
expect(calls).toEqual(["abc"])
})
test("flush is a no-op when empty", () => {
const calls: string[] = []
const writer = terminalWriter(
(data) => calls.push(data),
(flush) => flush(),
)
writer.flush()
expect(calls).toEqual([])
})
})

View File

@@ -0,0 +1,27 @@
export function terminalWriter(
write: (data: string) => void,
schedule: (flush: VoidFunction) => void = queueMicrotask,
) {
let chunks: string[] | undefined
let scheduled = false
const flush = () => {
scheduled = false
const items = chunks
if (!items?.length) return
chunks = undefined
write(items.join(""))
}
const push = (data: string) => {
if (!data) return
if (chunks) chunks.push(data)
else chunks = [data]
if (scheduled) return
scheduled = true
schedule(flush)
}
return { push, flush }
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.61",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.61",
"version": "1.1.64",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.61",
"version": "1.1.64",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.61",
"version": "1.1.64",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.61",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -8,6 +8,8 @@ const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await $`cd ../opencode && bun run build --single`
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)

View File

@@ -8,17 +8,17 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "opencode-darwin-x64",
ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "opencode-windows-x64",
ocBinary: "opencode-windows-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
ocBinary: "opencode-linux-x64",
ocBinary: "opencode-linux-x64-baseline",
assetExt: "tar.gz",
},
{

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.61",
"version": "1.1.64",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -224,7 +224,6 @@ export default function () {
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
expandedSteps: {} as Record<string, boolean>,
})
const messages = createMemo(() =>
data().sessionID
@@ -296,10 +295,7 @@ export default function () {
{(message) => (
<SessionTurn
sessionID={data().sessionID}
sessionTitle={info().title}
messageID={message.id}
stepsExpanded={store.expandedSteps[message.id] ?? false}
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
@@ -375,13 +371,6 @@ export default function () {
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
stepsExpanded={
store.expandedSteps[store.messageId ?? firstUserMessage()!.id!] ?? false
}
onStepsExpandedToggle={() => {
const id = store.messageId ?? firstUserMessage()!.id!
setStore("expandedSteps", id, (v) => !v)
}}
classes={{
root: "grow",
content: "flex flex-col justify-between",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.61"
version = "1.1.64"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.61",
"version": "1.1.64",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -47,20 +47,109 @@ if (!arch) {
const base = "opencode-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode.exe" : "opencode"
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
if (result.status !== 0) return false
return (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const cmd =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const out = (result.stdout || "").trim().toLowerCase()
if (out === "true" || out === "1") return true
if (out === "false" || out === "0") return false
} catch {
continue
}
}
return false
}
return false
}
const names = (() => {
const avx2 = supportsAvx2()
const baseline = arch === "x64" && !avx2
if (platform === "linux") {
const musl = (() => {
try {
if (fs.existsSync("/etc/alpine-release")) return true
} catch {
// ignore
}
try {
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase()
if (text.includes("musl")) return true
} catch {
// ignore
}
return false
})()
if (musl) {
if (arch === "x64") {
if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
}
return [`${base}-musl`, base]
}
if (arch === "x64") {
if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
}
return [base, `${base}-musl`]
}
if (arch === "x64") {
if (baseline) return [`${base}-baseline`, base]
return [base, `${base}-baseline`]
}
return [base]
})()
function findBinary(startDir) {
let current = startDir
for (;;) {
const modules = path.join(current, "node_modules")
if (fs.existsSync(modules)) {
const entries = fs.readdirSync(modules)
for (const entry of entries) {
if (!entry.startsWith(base)) {
continue
}
const candidate = path.join(modules, entry, "bin", binary)
if (fs.existsSync(candidate)) {
return candidate
}
for (const name of names) {
const candidate = path.join(modules, name, "bin", binary)
if (fs.existsSync(candidate)) return candidate
}
}
const parent = path.dirname(current)
@@ -74,9 +163,9 @@ function findBinary(startDir) {
const resolved = findBinary(scriptDir)
if (!resolved) {
console.error(
'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' +
base +
'" package',
"It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " +
names.map((n) => `\"${n}\"`).join(" or ") +
" package",
)
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.61",
"version": "1.1.64",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,7 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@@ -180,6 +181,7 @@ export function tui(input: {
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
@@ -209,6 +211,35 @@ function App() {
const exit = useExit()
const promptRef = usePromptRef()
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (!renderer.getSelection()) return
// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
// - Esc dismisses selection
// - Most other key input dismisses selection and is passed through
if (evt.ctrl && evt.name === "c") {
if (!Selection.copy(renderer, toast)) {
renderer.clearSelection()
return
}
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "escape") {
renderer.clearSelection()
evt.preventDefault()
evt.stopPropagation()
return
}
renderer.clearSelection()
})
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
@@ -216,6 +247,7 @@ function App() {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
@@ -702,19 +734,15 @@ function App() {
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseUp={async () => {
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
renderer.clearSelection()
return
}
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
<Switch>
<Match when={route.data.type === "home"}>

View File

@@ -1,10 +1,11 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection"
export function Dialog(
props: ParentProps<{
@@ -16,10 +17,18 @@ export function Dialog(
const { theme } = useTheme()
const renderer = useRenderer()
let dismiss = false
return (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
onMouseDown={() => {
dismiss = !!renderer.getSelection()
}}
onMouseUp={() => {
if (dismiss) {
dismiss = false
return
}
props.onClose?.()
}}
width={dimensions().width}
@@ -32,8 +41,8 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
onMouseUp={(e) => {
dismiss = false
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@@ -56,8 +65,13 @@ function init() {
size: "medium" as "medium" | "large",
})
const renderer = useRenderer()
useKeyboard((evt) => {
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -67,7 +81,6 @@ function init() {
}
})
const renderer = useRenderer()
let focus: Renderable | null
function refocus() {
setTimeout(() => {
@@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>

View File

@@ -0,0 +1,25 @@
import { Clipboard } from "./clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
error: (err: unknown) => void
}
type Renderer = {
getSelection: () => { getSelectedText: () => string } | null
clearSelection: () => void
}
export namespace Selection {
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
return true
}
}

View File

@@ -175,8 +175,14 @@ export namespace Config {
}
// Inline config content overrides all non-managed config sources.
// Route through load() to enable {env:} and {file:} token substitution.
// Use a path within Instance.directory so relative {file:} paths resolve correctly.
// The filename "OPENCODE_CONFIG_CONTENT" appears in error messages for clarity.
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
result = mergeConfigConcatArrays(
result,
await load(Flag.OPENCODE_CONFIG_CONTENT, path.join(Instance.directory, "OPENCODE_CONFIG_CONTENT")),
)
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}

View File

@@ -8,7 +8,7 @@ export namespace Flag {
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export declare const OPENCODE_CONFIG_CONTENT: string | undefined
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
@@ -37,7 +37,10 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
export const OPENCODE_ENABLE_EXA =
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
@@ -91,3 +94,14 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CONFIG_CONTENT
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_CONFIG_CONTENT", {
get() {
return process.env["OPENCODE_CONFIG_CONTENT"]
},
enumerable: true,
configurable: false,
})

View File

@@ -366,9 +366,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"gpt-5.1-codex",
])
for (const modelId of Object.keys(provider.models)) {
if (!allowedModels.has(modelId)) {
delete provider.models[modelId]
}
if (modelId.includes("codex")) continue
if (allowedModels.has(modelId)) continue
delete provider.models[modelId]
}
if (!provider.models["gpt-5.3-codex"]) {

View File

@@ -14,6 +14,8 @@ import { Env } from "../env"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
// Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -1229,9 +1231,19 @@ export namespace Provider {
const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
const providers = await list()
const recent = (await Bun.file(path.join(Global.Path.state, "model.json"))
.json()
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
.catch(() => [])) as { providerID: string; modelID: string }[]
for (const entry of recent) {
const provider = providers[entry.providerID]
if (!provider) continue
if (!provider.models[entry.modelID]) continue
return { providerID: entry.providerID, modelID: entry.modelID }
}
const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")

View File

@@ -4,7 +4,6 @@ import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
@@ -17,6 +16,22 @@ export namespace Pty {
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const sockets = new WeakMap<object, number>()
let socketCounter = 0
const tagSocket = (ws: Socket) => {
if (!ws || typeof ws !== "object") return
const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
sockets.set(ws, next)
return next
}
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -81,7 +96,7 @@ export namespace Pty {
buffer: string
bufferCursor: number
cursor: number
subscribers: Set<WSContext>
subscribers: Map<Socket, number>
}
const state = Instance.state(
@@ -91,8 +106,12 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
}
sessions.clear()
@@ -154,18 +173,26 @@ export namespace Pty {
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Set(),
subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData((data) => {
session.cursor += data.length
for (const ws of session.subscribers) {
for (const [ws, id] of session.subscribers) {
if (ws.readyState !== 1) {
session.subscribers.delete(ws)
continue
}
ws.send(data)
if (typeof ws === "object" && sockets.get(ws) !== id) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(data)
} catch {
session.subscribers.delete(ws)
}
}
session.buffer += data
@@ -177,14 +204,15 @@ export namespace Pty {
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
for (const ws of session.subscribers) {
ws.close()
}
state().delete(id)
})
Bus.publish(Event.Created, { info })
@@ -211,9 +239,14 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
state().delete(id)
Bus.publish(Event.Deleted, { id })
}
@@ -232,7 +265,7 @@ export namespace Pty {
}
}
export function connect(id: string, ws: WSContext, cursor?: number) {
export function connect(id: string, ws: Socket, cursor?: number) {
const session = state().get(id)
if (!session) {
ws.close()
@@ -272,7 +305,8 @@ export namespace Pty {
return
}
session.subscribers.add(ws)
const socketId = tagSocket(ws)
if (typeof socketId === "number") session.subscribers.set(ws, socketId)
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))

View File

@@ -160,9 +160,25 @@ export const PtyRoutes = lazy(() =>
})()
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const isSocket = (value: unknown): value is Socket => {
if (!value || typeof value !== "object") return false
if (!("readyState" in value)) return false
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
return typeof (value as { readyState?: unknown }).readyState === "number"
}
return {
onOpen(_event, ws) {
handler = Pty.connect(id, ws, cursor)
const socket = isSocket(ws.raw) ? ws.raw : ws
handler = Pty.connect(id, socket, cursor)
},
onMessage(event) {
handler?.onMessage(String(event.data))
@@ -170,6 +186,9 @@ export const PtyRoutes = lazy(() =>
onClose() {
handler?.onClose()
},
onError() {
handler?.onClose()
},
}
}),
),

View File

@@ -1800,3 +1800,68 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
}
})
})
// OPENCODE_CONFIG_CONTENT should support {env:} and {file:} token substitution
// just like file-based config sources do.
describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
const originalTestVar = process.env["TEST_CONFIG_VAR"]
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{env:TEST_CONFIG_VAR}",
})
try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_api_key_12345")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
} else {
delete process.env["OPENCODE_CONFIG_CONTENT"]
}
if (originalTestVar !== undefined) {
process.env["TEST_CONFIG_VAR"] = originalTestVar
} else {
delete process.env["TEST_CONFIG_VAR"]
}
}
})
test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{file:./api_key.txt}",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_key_from_file")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
} else {
delete process.env["OPENCODE_CONFIG_CONTENT"]
}
}
})
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Project } from "../../src/project/project"
import { describe, expect, mock, test } from "bun:test"
import type { Project as ProjectNS } from "../../src/project/project"
import { Log } from "../../src/util/log"
import { Storage } from "../../src/storage/storage"
import { $ } from "bun"
@@ -8,12 +8,78 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const bunModule = await import("bun")
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
let mode: Mode = "none"
function render(parts: TemplateStringsArray, vals: unknown[]) {
return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
}
function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) {
const result = {
exitCode: output.exitCode,
stdout: Buffer.from(output.stdout),
stderr: Buffer.from(output.stderr),
text: async () => output.stdout,
}
const shell = {
quiet: () => shell,
nothrow: () => shell,
cwd: () => shell,
env: () => shell,
text: async () => output.stdout,
then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) =>
Promise.resolve(result).then(onfulfilled, onrejected),
catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected),
finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally),
}
return shell
}
mock.module("bun", () => ({
...bunModule,
$: (parts: TemplateStringsArray, ...vals: unknown[]) => {
const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim()
if (
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("--all")
) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
}
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
}
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
}
return (bunModule.$ as any)(parts, ...vals)
},
}))
async function withMode(next: Mode, run: () => Promise<void>) {
const prev = mode
mode = next
try {
await run()
} finally {
mode = prev
}
}
async function loadProject() {
return (await import("../../src/project/project")).Project
}
describe("Project.fromDirectory", () => {
test("should handle git repository with no commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
expect(project).toBeDefined()
expect(project.id).toBe("global")
@@ -26,9 +92,10 @@ describe("Project.fromDirectory", () => {
})
test("should handle git repository with commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
expect(project).toBeDefined()
expect(project.id).not.toBe("global")
@@ -39,13 +106,51 @@ describe("Project.fromDirectory", () => {
const fileExists = await Bun.file(opencodeFile).exists()
expect(fileExists).toBe(true)
})
test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
const p = await loadProject()
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
await withMode("rev-list-fail", async () => {
const { project } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.id).toBe("global")
expect(project.worktree).toBe(tmp.path)
})
})
test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
await withMode("top-fail", async () => {
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
})
test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
await withMode("common-dir-fail", async () => {
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.vcs).toBe("git")
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
})
})
describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await Project.fromDirectory(tmp.path)
const { project, sandbox } = await p.fromDirectory(tmp.path)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
@@ -53,12 +158,13 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("should set worktree to root when called from a worktree", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const worktreePath = path.join(tmp.path, "..", "worktree-test")
await $`git worktree add ${worktreePath} -b test-branch`.cwd(tmp.path).quiet()
const { project, sandbox } = await Project.fromDirectory(worktreePath)
const { project, sandbox } = await p.fromDirectory(worktreePath)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath)
@@ -69,6 +175,7 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const worktree1 = path.join(tmp.path, "..", "worktree-1")
@@ -76,8 +183,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-1`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-2`.cwd(tmp.path).quiet()
await Project.fromDirectory(worktree1)
const { project } = await Project.fromDirectory(worktree2)
await p.fromDirectory(worktree1)
const { project } = await p.fromDirectory(worktree2)
expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1)
@@ -91,15 +198,16 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => {
test("should discover favicon.png in root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await Project.discover(project)
await p.discover(project)
const updated = await Storage.read<Project.Info>(["project", project.id])
const updated = await Storage.read<ProjectNS.Info>(["project", project.id])
expect(updated.icon).toBeDefined()
expect(updated.icon?.url).toStartWith("data:")
expect(updated.icon?.url).toContain("base64")
@@ -107,14 +215,15 @@ describe("Project.discover", () => {
})
test("should not discover non-image files", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await p.fromDirectory(tmp.path)
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await Project.discover(project)
await p.discover(project)
const updated = await Storage.read<Project.Info>(["project", project.id])
const updated = await Storage.read<ProjectNS.Info>(["project", project.id])
expect(updated.icon).toBeUndefined()
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
const b = await Pty.create({ command: "cat", title: "b" })
try {
const outA: string[] = []
const outB: string[] = []
const ws = {
readyState: 1,
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
// Connect "a" first with ws.
Pty.connect(a.id, ws as any)
// Now "reuse" the same ws object for another connection.
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.connect(b.id, ws as any)
// Clear connect metadata writes.
outA.length = 0
outB.length = 0
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
await Pty.remove(b.id)
}
},
})
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.61",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.61",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.61",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.61",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -4,15 +4,44 @@
display: flex;
align-items: center;
align-self: stretch;
gap: 20px;
justify-content: space-between;
gap: 0px;
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
width: 100%;
width: auto;
display: flex;
align-items: center;
align-self: stretch;
gap: 20px;
gap: 8px;
}
[data-slot="basic-tool-tool-indicator"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="basic-tool-tool-spinner"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="icon-svg"] {
@@ -20,16 +49,17 @@
}
[data-slot="basic-tool-tool-info"] {
flex-grow: 1;
flex: 0 1 auto;
min-width: 0;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
width: 100%;
width: auto;
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
justify-content: flex-start;
}
[data-slot="basic-tool-tool-info-main"] {
@@ -43,16 +73,21 @@
[data-slot="basic-tool-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--text-strong);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
}
[data-slot="basic-tool-tool-subtitle"] {
@@ -62,12 +97,12 @@
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
color: var(--text-base);
&.clickable {
cursor: pointer;
@@ -78,6 +113,26 @@
color: var(--text-base);
}
}
&.subagent-link {
color: var(--text-interactive-base);
text-decoration: none;
text-underline-offset: 2px;
font-weight: var(--font-weight-regular);
&:hover {
color: var(--text-interactive-base);
text-decoration: underline;
}
&:active {
color: var(--text-interactive-base);
}
&:visited {
color: var(--text-interactive-base);
}
}
}
[data-slot="basic-tool-tool-arg"] {
@@ -87,11 +142,11 @@
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
color: var(--text-base);
}
}

View File

@@ -1,6 +1,7 @@
import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible"
import { Icon, IconProps } from "./icon"
import { Spinner } from "./spinner"
export type TriggerTitle = {
title: string
@@ -22,6 +23,7 @@ export interface BasicToolProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
@@ -31,22 +33,23 @@ export interface BasicToolProps {
export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const pending = () => props.status === "pending" || props.status === "running"
createEffect(() => {
if (props.forceOpen) setOpen(true)
})
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
setOpen(value)
}
return (
<Collapsible open={open()} onOpenChange={handleOpenChange}>
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<Icon name={props.icon} size="small" />
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
@@ -61,39 +64,46 @@ export function BasicTool(props: BasicToolProps) {
>
{trigger().title}
</span>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
<Show when={pending()}>
<span data-slot="basic-tool-tool-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={trigger().action}>{trigger().action}</Show>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
@@ -101,7 +111,7 @@ export function BasicTool(props: BasicToolProps) {
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked}>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
@@ -113,6 +123,6 @@ export function BasicTool(props: BasicToolProps) {
)
}
export function GenericTool(props: { tool: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" status={props.status} trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
}

View File

@@ -2,23 +2,44 @@
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--surface-inset-base);
border: 1px solid var(--border-weaker-base);
background-color: transparent;
border: none;
transition: background-color 0.15s ease;
border-radius: var(--radius-md);
overflow: clip;
&.tool-collapsible {
gap: 8px;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
height: 32px;
padding: 6px 8px 6px 12px;
padding: 0;
align-items: center;
align-self: stretch;
cursor: default;
user-select: none;
color: var(--text-base);
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
}
[data-slot="collapsible-arrow-icon"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: inline-flex;
}
&:hover [data-slot="collapsible-arrow"] {
opacity: 1;
}
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@@ -48,6 +69,20 @@
}
}
[data-slot="collapsible-trigger"][aria-expanded="true"] {
[data-slot="collapsible-arrow"] {
opacity: 1;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="down"] {
display: inline-flex;
}
}
[data-slot="collapsible-content"] {
overflow: hidden;
/* animation: slideUp 250ms ease-out; */

View File

@@ -34,7 +34,12 @@ function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
function CollapsibleArrow(props?: ComponentProps<"div">) {
return (
<div data-slot="collapsible-arrow" {...(props || {})}>
<Icon name="chevron-grabber-vertical" size="small" />
<span data-slot="collapsible-arrow-icon" data-direction="right">
<Icon name="chevron-right" size="small" />
</span>
<span data-slot="collapsible-arrow-icon" data-direction="down">
<Icon name="chevron-down" size="small" />
</span>
</div>
)
}

View File

@@ -7,7 +7,7 @@
[data-slot="diff-changes-additions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
@@ -19,7 +19,7 @@
[data-slot="diff-changes-deletions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);

View File

@@ -3,7 +3,7 @@
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
color: var(--text-base);
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: var(--line-height-x-large);
@@ -117,7 +117,7 @@
.shiki {
font-size: 13px;
padding: 8px 12px;
border-radius: 4px;
border-radius: 6px;
border: 0.5px solid var(--border-weak-base);
}
@@ -127,11 +127,55 @@
[data-slot="markdown-copy-button"] {
position: absolute;
top: 8px;
right: 8px;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1;
&::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 4px);
transform: translateX(-50%);
z-index: 1000;
max-width: 320px;
border-radius: var(--radius-sm);
background: var(--surface-float-base);
color: var(--text-invert-strong);
padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);
pointer-events: none;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
opacity: 0;
transition: opacity 0.15s ease;
}
}
[data-slot="markdown-copy-button"]:hover::after,
[data-slot="markdown-copy-button"]:focus-visible::after {
opacity: 1;
}
[data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {

View File

@@ -72,7 +72,7 @@ function createCopyButton(labels: CopyLabels) {
button.setAttribute("data-size", "small")
button.setAttribute("data-slot", "markdown-copy-button")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("title", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
button.appendChild(createIcon(iconPaths.check, "check-icon"))
return button
@@ -82,12 +82,12 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
if (copied) {
button.setAttribute("data-copied", "true")
button.setAttribute("aria-label", labels.copied)
button.setAttribute("title", labels.copied)
button.setAttribute("data-tooltip", labels.copied)
return
}
button.removeAttribute("data-copied")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("title", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
}
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {

View File

@@ -14,15 +14,23 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--text-strong);
display: flex;
flex-direction: column;
align-items: flex-end;
align-self: stretch;
width: 100%;
max-width: 100%;
gap: 8px;
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
}
[data-slot="user-message-attachment"] {
@@ -71,15 +79,24 @@
}
}
[data-slot="user-message-body"] {
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
}
[data-slot="user-message-text"] {
position: relative;
display: inline-block;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
background: var(--surface-weak);
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
padding: 8px 12px;
border-radius: 4px;
border-radius: 6px;
[data-highlight="file"] {
color: var(--syntax-property);
@@ -89,19 +106,32 @@
color: var(--syntax-type);
}
[data-slot="user-message-copy-wrapper"] {
position: absolute;
top: 7px;
right: 7px;
opacity: 0;
transition: opacity 0.15s ease;
}
max-width: 100%;
}
&:hover [data-slot="user-message-copy-wrapper"] {
opacity: 1;
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
&:hover [data-slot="user-message-copy-wrapper"],
&:focus-within [data-slot="user-message-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
.text-text-strong {
color: var(--text-strong);
}
@@ -115,21 +145,30 @@
width: 100%;
[data-slot="text-part-body"] {
position: relative;
margin-top: 32px;
margin-top: 0;
}
[data-slot="text-part-copy-wrapper"] {
position: absolute;
top: -28px;
right: 8px;
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 1;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
[data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] {
&:hover [data-slot="text-part-copy-wrapper"],
&:focus-within [data-slot="text-part-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
[data-component="markdown"] {
@@ -146,7 +185,7 @@
[data-component="markdown"] {
margin-top: 24px;
font-style: italic !important;
font-style: normal;
p:has(strong) {
margin-top: 24px;
@@ -196,7 +235,8 @@
[data-component="tool-output"] {
white-space: pre;
padding: 8px 12px;
padding: 0;
margin-bottom: 24px;
height: fit-content;
display: flex;
flex-direction: column;
@@ -238,6 +278,78 @@
}
}
[data-slot="collapsible-content"]:has([data-component="edit-content"]),
[data-slot="collapsible-content"]:has([data-component="write-content"]) {
border: 1px solid var(--border-weak-base);
border-radius: 6px;
background: transparent;
overflow: hidden;
}
[data-component="bash-output"] {
width: 100%;
border: 1px solid var(--border-weak-base);
border-radius: 6px;
background: transparent;
position: relative;
overflow: hidden;
[data-slot="bash-copy"] {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
&:hover [data-slot="bash-copy"],
&:focus-within [data-slot="bash-copy"] {
opacity: 1;
pointer-events: auto;
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-slot="bash-scroll"] {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="bash-pre"] {
margin: 0;
padding: 12px;
}
[data-slot="bash-pre"] code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 13px;
line-height: var(--line-height-large);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
[data-slot="collapsible-content"]:has([data-component="edit-content"]) [data-component="edit-content"],
[data-slot="collapsible-content"]:has([data-component="write-content"]) [data-component="write-content"] {
border-top: none;
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
display: flex;
@@ -260,7 +372,7 @@
align-items: center;
gap: 4px;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
@@ -268,18 +380,37 @@
color: var(--text-base);
}
[data-slot="message-part-title-spinner"] {
margin-left: 4px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory"] {
@@ -344,7 +475,7 @@
}
[data-component="todos"] {
padding: 10px 12px 24px 48px;
padding: 10px 0 24px 0;
display: flex;
flex-direction: column;
gap: 8px;
@@ -357,25 +488,17 @@
}
}
[data-component="task-tools"] {
padding: 8px 12px;
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: flex-start;
gap: 0px;
cursor: pointer;
[data-slot="task-tool-item"] {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-weak);
[data-slot="icon-svg"] {
flex-shrink: 0;
color: var(--icon-weak);
}
}
[data-slot="task-tool-title"] {
[data-slot="context-tool-group-title"] {
min-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
@@ -383,15 +506,20 @@
color: var(--text-weak);
}
[data-slot="task-tool-subtitle"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-weaker);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
}
}
[data-component="context-tool-group-list"] {
padding: 6px 0 4px 0;
display: flex;
flex-direction: column;
gap: 2px;
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 0;
}
}
@@ -720,7 +848,7 @@
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 12px;
padding: 8px 0;
[data-slot="question-answer-item"] {
display: flex;

View File

@@ -37,18 +37,18 @@ import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { Button } from "./button"
import { Card } from "./card"
import { Collapsible } from "./collapsible"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview"
import { findLast } from "@opencode-ai/util/array"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Spinner } from "./spinner"
import { TextShimmer } from "./text-shimmer"
interface Diagnostic {
range: {
@@ -92,6 +92,7 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
export interface MessageProps {
message: MessageType
parts: PartType[]
showAssistantCopyPartID?: string
}
export interface MessagePartProps {
@@ -99,6 +100,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string
}
export type PartComponent = Component<MessagePartProps>
@@ -107,12 +109,6 @@ export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
const TEXT_RENDER_THROTTLE_MS = 100
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function createThrottledValue(getValue: () => string) {
const [value, setValue] = createSignal(getValue())
let timeout: ReturnType<typeof setTimeout> | undefined
@@ -157,22 +153,6 @@ function getDirectory(path: string | undefined) {
return relativizeProjectPaths(_getDirectory(path), data.directory)
}
export function getSessionToolParts(store: ReturnType<typeof useData>["store"], sessionId: string): ToolPart[] {
const messages = store.message[sessionId]?.filter((m) => m.role === "assistant")
if (!messages) return []
const parts: ToolPart[] = []
for (const m of messages) {
const msgParts = store.part[m.id]
if (msgParts) {
for (const p of msgParts) {
if (p && p.type === "tool") parts.push(p as ToolPart)
}
}
}
return parts
}
import type { IconProps } from "./icon"
export type ToolInfo = {
@@ -269,6 +249,86 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
}
}
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
function isContextGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
}
function contextToolDetail(part: ToolPart): string | undefined {
const info = getToolInfo(part.tool, part.state.input ?? {})
if (info.subtitle) return info.subtitle
if (part.state.status === "error") return part.state.error
if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
return part.state.title
const description = part.state.input?.description
if (typeof description === "string") return description
return undefined
}
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
const input = (part.state.input ?? {}) as Record<string, unknown>
const path = typeof input.path === "string" ? input.path : "/"
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
const include = typeof input.include === "string" ? input.include : undefined
const offset = typeof input.offset === "number" ? input.offset : undefined
const limit = typeof input.limit === "number" ? input.limit : undefined
switch (part.tool) {
case "read": {
const args: string[] = []
if (offset !== undefined) args.push("offset=" + offset)
if (limit !== undefined) args.push("limit=" + limit)
return {
title: i18n.t("ui.tool.read"),
subtitle: filePath ? getFilename(filePath) : "",
args,
}
}
case "list":
return {
title: i18n.t("ui.tool.list"),
subtitle: getDirectory(path),
}
case "glob":
return {
title: "Search",
subtitle: getDirectory(path),
args: pattern ? ["pattern=" + pattern] : [],
}
case "grep": {
const args: string[] = []
if (pattern) args.push("pattern=" + pattern)
if (include) args.push("include=" + include)
return {
title: "Search",
subtitle: getDirectory(path),
args,
}
}
default: {
const info = getToolInfo(part.tool, input)
return {
title: info.title,
subtitle: info.subtitle || contextToolDetail(part),
args: [],
}
}
}
}
function contextToolSummary(parts: ToolPart[]) {
const read = parts.filter((part) => part.tool === "read").length
const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
const list = parts.filter((part) => part.tool === "list").length
return [
read ? `${read} ${read === 1 ? "read" : "reads"}` : undefined,
search ? `${search} ${search === 1 ? "search" : "searches"}` : undefined,
list ? `${list} ${list === 1 ? "list" : "lists"}` : undefined,
].filter((value): value is string => !!value)
}
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@@ -281,47 +341,162 @@ export function Message(props: MessageProps) {
</Match>
<Match when={props.message.role === "assistant" && props.message}>
{(assistantMessage) => (
<AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
<AssistantMessageDisplay
message={assistantMessage() as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)}
</Match>
</Switch>
)
}
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
const emptyParts: PartType[] = []
const filteredParts = createMemo(
() =>
props.parts.filter((x) => {
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
}),
emptyParts,
{ equals: same },
export function AssistantMessageDisplay(props: {
message: AssistantMessage
parts: PartType[]
showAssistantCopyPartID?: string
}) {
const grouped = createMemo(() => {
const keys: string[] = []
const items: Record<string, { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }> = {}
const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => {
keys.push(key)
items[key] = item
}
const parts = props.parts
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
push(`context:${first.id}`, {
type: "context",
parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)),
})
start = -1
}
parts.forEach((part, index) => {
if (isContextGroupTool(part)) {
if (start < 0) start = index
return
}
flush(index - 1)
push(`part:${part.id}`, { type: "part", part })
})
flush(parts.length - 1)
return { keys, items }
})
return (
<For each={grouped().keys}>
{(key) => {
const item = createMemo(() => grouped().items[key])
return (
<Show when={item()}>
{(value) => {
const entry = value()
if (entry.type === "context") return <ContextToolGroup parts={entry.parts} />
return (
<Part
part={entry.part}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)
}}
</Show>
)
}}
</For>
)
}
function ContextToolGroup(props: { parts: ToolPart[] }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(() =>
props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
const summary = createMemo(() => contextToolSummary(props.parts))
const details = createMemo(() => {
const items = summary()
if (items.length === 0) return ""
return `: ${items.join(", ")}`
})
return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<Show
when={pending()}
fallback={<span data-slot="context-tool-group-title">Gathered context{details()}</span>}
>
<span data-slot="context-tool-group-title">
<TextShimmer text="Gathering context" />
{details()}
</span>
</Show>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-component="context-tool-group-list">
<For each={props.parts}>
{(part) => {
const trigger = contextToolTrigger(part, i18n)
const running = part.state.status === "pending" || part.state.status === "running"
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">{trigger.title}</span>
<Show when={running}>
<span data-slot="basic-tool-tool-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!running && trigger.subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
</Show>
<Show when={!running && trigger.args?.length}>
<For each={trigger.args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</Show>
</div>
</div>
</div>
</div>
</div>
</div>
)
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
)
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const dialog = useDialog()
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const [expanded, setExpanded] = createSignal(false)
const [canExpand, setCanExpand] = createSignal(false)
let textRef: HTMLDivElement | undefined
const updateCanExpand = () => {
const el = textRef
if (!el) return
if (expanded()) return
setCanExpand(el.scrollHeight > el.clientHeight + 2)
}
createResizeObserver(
() => textRef,
() => {
updateCanExpand()
},
)
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -329,11 +504,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const text = createMemo(() => textPart()?.text || "")
createEffect(() => {
text()
updateCanExpand()
})
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
const attachments = createMemo(() =>
@@ -364,13 +534,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
setTimeout(() => setCopied(false), 2000)
}
const toggleExpanded = () => {
if (!canExpand()) return
setExpanded((value) => !value)
}
return (
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
<div data-component="user-message">
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -404,29 +569,20 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</div>
</Show>
<Show when={text()}>
<div data-slot="user-message-text" ref={(el) => (textRef = el)} onClick={toggleExpanded}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<button
data-slot="user-message-expand"
type="button"
aria-label={expanded() ? i18n.t("ui.message.collapse") : i18n.t("ui.message.expand")}
onClick={(event) => {
event.stopPropagation()
toggleExpanded()
}}
>
<Icon name="chevron-down" size="small" />
</button>
<div data-slot="user-message-body">
<div data-slot="user-message-text">
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
<div data-slot="user-message-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
@@ -491,6 +647,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
</Show>
)
@@ -536,6 +693,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = props.part as ToolPart
if (part.tool === "todowrite" || part.tool === "todoread") return null
const hideQuestion = createMemo(
() => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"),
)
const permission = createMemo(() => {
const next = data.store.permission?.[props.message.sessionID]?.[0]
@@ -604,65 +766,67 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const render = ToolRegistry.render(part.tool) ?? GenericTool
return (
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
<div data-component="tool-error">
<Icon name="circle-ban-sign" size="small" />
<Switch>
<Match when={title && title.length < 30}>
<div data-slot="message-part-tool-error-content">
<div data-slot="message-part-tool-error-title">{title}</div>
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
</div>
</Match>
<Match when={true}>
<span data-slot="message-part-tool-error-message">{cleaned}</span>
</Match>
</Switch>
</div>
</Card>
)
}}
</Match>
<Match when={true}>
<Dynamic
component={render}
input={input()}
tool={part.tool}
metadata={metadata()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
forceOpen={forceOpen()}
locked={showPermission() || showQuestion()}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
<Show when={showPermission() && permission()}>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
<Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
<div data-component="tool-error">
<Icon name="circle-ban-sign" size="small" />
<Switch>
<Match when={title && title.length < 30}>
<div data-slot="message-part-tool-error-content">
<div data-slot="message-part-tool-error-title">{title}</div>
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
</div>
</Match>
<Match when={true}>
<span data-slot="message-part-tool-error-message">{cleaned}</span>
</Match>
</Switch>
</div>
</Card>
)
}}
</Match>
<Match when={true}>
<Dynamic
component={render}
input={input()}
tool={part.tool}
metadata={metadata()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
forceOpen={forceOpen()}
locked={showPermission() || showQuestion()}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
<Show when={showPermission() && permission()}>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</div>
</Show>
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
</div>
</Show>
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
</div>
</Show>
)
}
@@ -672,6 +836,17 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const part = props.part as TextPart
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
const throttledText = createThrottledValue(displayText)
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.at(-1)
return last?.id === part.id
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID) return props.showAssistantCopyPartID === part.id
return isLastTextPart()
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
@@ -687,23 +862,25 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part.id} />
</div>
<Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</div>
</Show>
</div>
</Show>
)
@@ -844,29 +1021,47 @@ ToolRegistry.register({
name: "webfetch",
render(props) {
const i18n = useI18n()
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const url = createMemo(() => {
const value = props.input.url
if (typeof value !== "string") return ""
return value
})
return (
<BasicTool
{...props}
hideDetails
icon="window-cursor"
trigger={{
title: i18n.t("ui.tool.webfetch"),
subtitle: props.input.url || "",
args: props.input.format ? ["format=" + props.input.format] : [],
action: (
<div data-component="tool-action">
<Icon name="square-arrow-top-right" size="small" />
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">{i18n.t("ui.tool.webfetch")}</span>
<Show when={pending()}>
<span data-slot="basic-tool-tool-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!pending() && url()}>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{url()}
</a>
</Show>
</div>
),
}}
>
<Show when={props.output}>
{(output) => (
<div data-component="tool-output" data-scrollable>
<Markdown text={output()} />
</div>
)}
</Show>
</BasicTool>
<Show when={!pending() && url()}>
<div data-component="tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</div>
}
/>
)
},
})
@@ -877,6 +1072,13 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const childSessionId = () => props.metadata.sessionId as string | undefined
const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }))
const description = createMemo(() => {
const value = props.input.description
if (typeof value === "string") return value
return undefined
})
const running = createMemo(() => props.status === "pending" || props.status === "running")
const href = createMemo(() => {
const sessionId = childSessionId()
@@ -892,14 +1094,6 @@ ToolRegistry.register({
return `${path.slice(0, idx)}/session/${sessionId}`
})
createEffect(() => {
const sessionId = childSessionId()
if (!sessionId) return
const sync = data.syncSession
if (!sync) return
Promise.resolve(sync(sessionId)).catch(() => undefined)
})
const handleLinkClick = (e: MouseEvent) => {
const sessionId = childSessionId()
const url = href()
@@ -921,23 +1115,30 @@ ToolRegistry.register({
}, 50)
}
const titleContent = () => <TextShimmer text={title()} active={running()} />
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title" class="capitalize">
{i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })}
<span data-slot="basic-tool-tool-title" class="capitalize agent-title">
{titleContent()}
</span>
<Show when={props.input.description}>
<Show when={description()}>
<Switch>
<Match when={href()}>
{(url) => (
<a data-slot="basic-tool-tool-subtitle" class="clickable" href={url()} onClick={handleLinkClick}>
{props.input.description}
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={url()}
onClick={handleLinkClick}
>
{description()}
</a>
)}
</Match>
<Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{props.input.description}</span>
<span data-slot="basic-tool-tool-subtitle">{description()}</span>
</Match>
</Switch>
</Show>
@@ -945,134 +1146,7 @@ ToolRegistry.register({
</div>
)
const childToolParts = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return []
return getSessionToolParts(data.store, sessionId)
})
const autoScroll = createAutoScroll({
working: () => true,
overflowAnchor: "auto",
})
const childPermission = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return undefined
const permissions = data.store.permission?.[sessionId] ?? []
return permissions[0]
})
const childToolPart = createMemo(() => {
const perm = childPermission()
if (!perm || !perm.tool) return undefined
const sessionId = childSessionId()
if (!sessionId) return undefined
// Find the tool part that matches the permission's callID
const messages = data.store.message[sessionId] ?? []
const message = findLast(messages, (m) => m.id === perm.tool!.messageID)
if (!message) return undefined
const parts = data.store.part[message.id] ?? []
for (const part of parts) {
if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) {
return { part: part as ToolPart, message }
}
}
return undefined
})
const respond = (response: "once" | "always" | "reject") => {
const perm = childPermission()
if (!perm || !data.respondToPermission) return
data.respondToPermission({
sessionID: perm.sessionID,
permissionID: perm.id,
response,
})
}
const renderChildToolPart = () => {
const toolData = childToolPart()
if (!toolData) return null
const { part } = toolData
const render = ToolRegistry.render(part.tool) ?? GenericTool
// @ts-expect-error
const metadata = part.state?.metadata ?? {}
const input = part.state?.input ?? {}
return (
<Dynamic
component={render}
input={input}
tool={part.tool}
metadata={metadata}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
defaultOpen={true}
/>
)
}
return (
<div data-component="tool-part-wrapper" data-permission={!!childPermission()}>
<Switch>
<Match when={childPermission()}>
<>
<Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen={true} trigger={trigger()} />}>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</>
</Match>
<Match when={true}>
<BasicTool icon="task" defaultOpen={true} trigger={trigger()}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
data-component="tool-output"
data-scrollable
>
<div ref={autoScroll.contentRef} data-component="task-tools">
<For each={childToolParts()}>
{(item) => {
const info = createMemo(() => getToolInfo(item.tool, item.state.input))
const subtitle = createMemo(() => {
if (info().subtitle) return info().subtitle
if (item.state.status === "completed" || item.state.status === "running") {
return item.state.title
}
})
return (
<div data-slot="task-tool-item">
<Icon name={info().icon} size="small" />
<span data-slot="task-tool-title">{info().title}</span>
<Show when={subtitle()}>
<span data-slot="task-tool-subtitle">{subtitle()}</span>
</Show>
</div>
)
}}
</For>
</div>
</div>
</BasicTool>
</Match>
</Switch>
</div>
)
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
},
})
@@ -1080,6 +1154,21 @@ ToolRegistry.register({
name: "bash",
render(props) {
const i18n = useI18n()
const text = createMemo(() => {
const cmd = props.input.command ?? props.metadata.command ?? ""
const out = stripAnsi(props.output || props.metadata.output || "")
return `$ ${cmd}${out ? "\n\n" + out : ""}`
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<BasicTool
{...props}
@@ -1089,10 +1178,28 @@ ToolRegistry.register({
subtitle: props.input.description,
}}
>
<div data-component="tool-output" data-scrollable>
<Markdown
text={`\`\`\`command\n$ ${props.input.command ?? props.metadata.command ?? ""}${props.output || props.metadata.output ? "\n\n" + stripAnsi(props.output || props.metadata.output) : ""}\n\`\`\``}
/>
<div data-component="bash-output">
<div data-slot="bash-copy">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
<div data-slot="bash-scroll" data-scrollable>
<pre data-slot="bash-pre">
<code>{text()}</code>
</pre>
</div>
</div>
</BasicTool>
)
@@ -1106,6 +1213,7 @@ ToolRegistry.register({
const diffComponent = useDiffComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
return (
<BasicTool
{...props}
@@ -1115,16 +1223,23 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.edit")}</span>
<span data-slot="message-part-title-filename">{filename()}</span>
<Show when={pending()}>
<span data-slot="message-part-title-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={props.input.filePath?.includes("/")}>
<Show when={!pending() && props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={props.metadata.filediff}>
<Show when={!pending() && props.metadata.filediff}>
<DiffChanges changes={props.metadata.filediff} />
</Show>
</div>
@@ -1159,6 +1274,7 @@ ToolRegistry.register({
const codeComponent = useCodeComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
return (
<BasicTool
{...props}
@@ -1168,9 +1284,16 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.write")}</span>
<span data-slot="message-part-title-filename">{filename()}</span>
<Show when={pending()}>
<span data-slot="message-part-title-spinner">
<Spinner style={{ width: "16px" }} />
</span>
</Show>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={props.input.filePath?.includes("/")}>
<Show when={!pending() && props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
</div>

View File

@@ -1,7 +1,5 @@
[data-component="session-turn"] {
--session-turn-sticky-height: 0px;
--sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 24px);
/* flex: 1; */
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@@ -30,525 +28,30 @@
min-width: 0;
gap: 18px;
overflow-anchor: none;
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
}
[data-slot="session-turn-attachments"] {
width: 100%;
min-width: 0;
align-self: stretch;
}
[data-slot="session-turn-sticky"] {
width: calc(100% + 9px);
position: sticky;
top: var(--session-title-height, 0px);
z-index: 20;
background-color: var(--background-stronger);
margin-left: -9px;
padding-left: 9px;
/* padding-bottom: 12px; */
display: flex;
flex-direction: column;
gap: 12px;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: var(--background-stronger);
z-index: -1;
}
&::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 32px;
background: linear-gradient(to bottom, var(--background-stronger), transparent);
pointer-events: none;
}
}
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
align-self: stretch;
height: 32px;
}
[data-slot="session-turn-message-content"] {
margin-top: 0;
width: 100%;
min-width: 0;
max-width: 100%;
}
[data-component="user-message"] [data-slot="user-message-text"] {
max-height: var(--user-message-collapsed-height, 64px);
}
[data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] {
max-height: none;
}
[data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] {
padding-right: 36px;
padding-bottom: 28px;
}
[data-component="user-message"][data-can-expand="true"]:not([data-expanded="true"])
[data-slot="user-message-text"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 8px;
bottom: 0px;
background:
linear-gradient(to bottom, transparent, var(--surface-weak)),
linear-gradient(to bottom, transparent, var(--surface-weak));
pointer-events: none;
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] {
display: none;
position: absolute;
bottom: 6px;
right: 6px;
padding: 0;
}
[data-component="user-message"][data-can-expand="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"],
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"] {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
width: 22px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--text-weak);
[data-slot="icon-svg"] {
transition: transform 0.15s ease;
}
}
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"]
[data-slot="icon-svg"] {
transform: rotate(180deg);
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover {
background: var(--surface-raised-base);
color: var(--text-base);
}
[data-slot="session-turn-user-badges"] {
display: flex;
align-items: center;
gap: 6px;
padding-left: 16px;
}
[data-slot="session-turn-message-title"] {
width: 100%;
font-size: var(--font-size-large);
font-weight: 500;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-message-title"] h1 {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
font-size: inherit;
font-weight: inherit;
}
[data-slot="session-turn-typewriter"] {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-summary-section"] {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
align-items: flex-start;
align-self: stretch;
}
[data-slot="session-turn-summary-header"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
[data-slot="session-turn-summary-title-row"] {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
[data-slot="session-turn-response"] {
width: 100%;
}
[data-slot="session-turn-response-copy-wrapper"] {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-response-copy-wrapper"],
&:focus-within [data-slot="session-turn-response-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
p {
font-size: var(--font-size-base);
line-height: var(--line-height-x-large);
}
}
[data-slot="session-turn-summary-title"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
color: var(--text-weak);
}
[data-slot="session-turn-markdown"],
[data-slot="session-turn-accordion"] [data-slot="accordion-content"] {
-webkit-user-select: text;
user-select: text;
}
[data-slot="session-turn-markdown"] {
&[data-diffs="true"] {
font-size: 15px;
}
&[data-fade="true"] > * {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
}
[data-slot="session-turn-summary-section"] {
position: relative;
[data-slot="session-turn-summary-copy"] {
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-summary-copy"] {
opacity: 1;
}
}
[data-slot="session-turn-accordion"] {
width: 100%;
}
[data-component="sticky-accordion-header"] {
top: var(--sticky-header-height, 0px);
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
top: calc(-1 * var(--sticky-header-height, 0px));
}
[data-slot="session-turn-accordion-trigger-content"] {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
[data-expandable="false"] {
pointer-events: none;
}
}
[data-slot="session-turn-file-info"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
[data-slot="session-turn-file-icon"] {
flex-shrink: 0;
width: 16px;
height: 16px;
}
[data-slot="session-turn-file-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
}
[data-slot="session-turn-directory"] {
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="session-turn-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="session-turn-accordion-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
[data-slot="session-turn-accordion-content"] {
max-height: 240px;
/* max-h-60 */
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-turn-accordion-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-turn-response-section"] {
width: calc(100% + 9px);
min-width: 0;
margin-left: -9px;
padding-left: 9px;
}
[data-slot="session-turn-collapsible"] {
gap: 32px;
overflow: visible;
}
[data-slot="session-turn-collapsible-trigger-content"] {
max-width: 100%;
min-width: 0;
[data-slot="session-turn-thinking"] {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-weak);
[data-slot="session-turn-trigger-icon"] {
color: var(--icon-base);
}
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
min-height: 20px;
[data-component="spinner"] {
width: 12px;
height: 12px;
margin-right: 4px;
width: 16px;
height: 16px;
}
[data-component="icon"] {
width: 14px;
height: 14px;
}
}
[data-slot="session-turn-retry-message"] {
font-weight: 500;
color: var(--syntax-critical);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="session-turn-retry-seconds"] {
color: var(--text-weak);
}
[data-slot="session-turn-retry-attempt"] {
color: var(--text-weak);
}
[data-slot="session-turn-status-text"] {
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="session-turn-details-text"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
}
.error-card {
@@ -560,44 +63,16 @@
overflow-y: auto;
}
[data-slot="session-turn-collapsible-content-inner"] {
[data-slot="session-turn-assistant-content"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 12px;
margin-left: 12px;
padding-left: 12px;
padding-right: 12px;
border-left: 1px solid var(--border-base);
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
}
}
[data-slot="session-turn-permission-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-question-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-answered-question-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
}

View File

@@ -1,31 +1,12 @@
import {
AssistantMessage,
FilePart,
Message as MessageType,
Part as PartType,
type PermissionRequest,
type QuestionRequest,
TextPart,
ToolPart,
} from "@opencode-ai/sdk/v2/client"
import { AssistantMessage, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { IconButton } from "./icon-button"
import { createMemo, For, ParentProps, Show } from "solid-js"
import { Message } from "./message-part"
import { Card } from "./card"
import { Button } from "./button"
import { Spinner } from "./spinner"
import { Tooltip } from "./tooltip"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
type Translator = (key: UiI18nKey, params?: UiI18nParams) => string
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
@@ -80,117 +61,42 @@ function unwrap(message: string) {
return message
}
function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined {
if (!part) return undefined
if (part.type === "tool") {
switch (part.tool) {
case "task":
return t("ui.sessionTurn.status.delegating")
case "todowrite":
case "todoread":
return t("ui.sessionTurn.status.planning")
case "read":
return t("ui.sessionTurn.status.gatheringContext")
case "list":
case "grep":
case "glob":
return t("ui.sessionTurn.status.searchingCodebase")
case "webfetch":
return t("ui.sessionTurn.status.searchingWeb")
case "edit":
case "write":
return t("ui.sessionTurn.status.makingEdits")
case "bash":
return t("ui.sessionTurn.status.runningCommands")
default:
return undefined
}
}
if (part.type === "reasoning") {
const text = part.text ?? ""
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() })
return t("ui.sessionTurn.status.thinking")
}
if (part.type === "text") {
return t("ui.sessionTurn.status.gatheringThoughts")
}
return undefined
}
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function isAttachment(part: PartType | undefined) {
if (part?.type !== "file") return false
const mime = (part as FilePart).mime ?? ""
return mime.startsWith("image/") || mime === "application/pdf"
}
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
}
function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
hidden?: () => readonly { messageID: string; callID: string }[]
}) {
const hidden = new Set(["todowrite", "todoread"])
function visible(part: PartType) {
if (part.type === "tool") {
if (hidden.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
return true
}
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return false
}
function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string }) {
const data = useData()
const emptyParts: PartType[] = []
const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts))
const lastTextPart = createMemo(() => {
const parts = msgParts()
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (part?.type === "text") return part as TextPart
}
return undefined
})
const filteredParts = createMemo(() => {
let parts = msgParts()
if (props.hideReasoning) {
parts = parts.filter((part) => part?.type !== "reasoning")
}
if (props.hideResponsePart) {
const responsePartId = props.responsePartId
if (responsePartId && responsePartId === lastTextPart()?.id) {
parts = parts.filter((part) => part?.id !== responsePartId)
}
}
const hidden = props.hidden?.() ?? []
if (hidden.length === 0) return parts
const id = props.message.id
return parts.filter((part) => {
if (part?.type !== "tool") return true
const tool = part as ToolPart
return !hidden.some((h) => h.messageID === id && h.callID === tool.callID)
})
})
return <Message message={props.message} parts={filteredParts()} />
return <Message message={props.message} parts={msgParts()} showAssistantCopyPartID={props.showAssistantCopyPartID} />
}
export function SessionTurn(
props: ParentProps<{
sessionID: string
sessionTitle?: string
messageID: string
lastUserMessageID?: string
stepsExpanded?: boolean
onStepsExpandedToggle?: () => void
onUserInteracted?: () => void
classes?: {
root?: string
@@ -199,16 +105,11 @@ export function SessionTurn(
}
}>,
) {
const i18n = useI18n()
const data = useData()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyQuestions: QuestionRequest[] = []
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@@ -256,19 +157,6 @@ export function SessionTurn(
return list(data.store.part?.[msg.id], emptyParts)
})
const attachmentParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyFiles
return msgParts.filter((part) => isAttachment(part)) as FilePart[]
})
const stickyParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyParts
if (attachmentParts().length === 0) return msgParts
return msgParts.filter((part) => !isAttachment(part))
})
const assistantMessages = createMemo(
() => {
const msg = message()
@@ -291,9 +179,24 @@ export function SessionTurn(
{ equals: same },
)
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
const showAssistantCopyPartID = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
const parts = list(data.store.part?.[message.id], emptyParts)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
}
}
return undefined
})
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@@ -301,309 +204,23 @@ export function SessionTurn(
return unwrap(String(msg))
})
const lastTextPart = createMemo(() => {
const msgs = assistantMessages()
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part?.type === "text") return part as TextPart
}
}
return undefined
})
const hasSteps = createMemo(() => {
for (const m of assistantMessages()) {
const msgParts = list(data.store.part?.[m.id], emptyParts)
for (const p of msgParts) {
if (p?.type === "tool") return true
}
}
return false
})
const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions))
const nextPermission = createMemo(() => permissions()[0])
const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions))
const nextQuestion = createMemo(() => questions()[0])
const hidden = createMemo(() => {
const out: { messageID: string; callID: string }[] = []
const perm = nextPermission()
if (perm?.tool) out.push(perm.tool)
const question = nextQuestion()
if (question?.tool) out.push(question.tool)
return out
})
const answeredQuestionParts = createMemo(() => {
if (props.stepsExpanded) return emptyQuestionParts
if (questions().length > 0) return emptyQuestionParts
const result: { part: ToolPart; message: AssistantMessage }[] = []
for (const msg of assistantMessages()) {
const parts = list(data.store.part?.[msg.id], emptyParts)
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.tool !== "question") continue
// @ts-expect-error metadata may not exist on all tool states
const answers = tool.state?.metadata?.answers
if (answers && answers.length > 0) {
result.push({ part: tool, message: msg })
}
}
}
return result
})
const shellModePart = createMemo(() => {
const p = parts()
if (p.length === 0) return
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
const msgs = assistantMessages()
if (msgs.length !== 1) return
const msgParts = list(data.store.part?.[msgs[0].id], emptyParts)
if (msgParts.length !== 1) return
const assistantPart = msgParts[0]
if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart
})
const isShellMode = createMemo(() => !!shellModePart())
const rawStatus = createMemo(() => {
const msgs = assistantMessages()
let last: PartType | undefined
let currentTask: ToolPart | undefined
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (!part) continue
if (!last) last = part
if (
part.type === "tool" &&
part.tool === "task" &&
part.state &&
"metadata" in part.state &&
part.state.metadata?.sessionId &&
part.state.status === "running"
) {
currentTask = part as ToolPart
break
}
}
if (currentTask) break
}
const taskSessionId =
currentTask?.state && "metadata" in currentTask.state
? (currentTask.state.metadata?.sessionId as string | undefined)
: undefined
if (taskSessionId) {
const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages)
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
const msg = taskMessages[mi]
if (!msg || msg.role !== "assistant") continue
const msgParts = list(data.store.part?.[msg.id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part) return computeStatusFromPart(part, i18n.t)
}
}
}
return computeStatusFromPart(last, i18n.t)
})
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
// session_status is session-scoped; only show retry on the active (last) turn
if (!isLastUserMessage()) return
const s = status()
if (s.type !== "retry") return
return s
})
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = response() ?? ""
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
const updateStickyHeight = (height: number) => {
const root = rootRef()
if (!root) return
const next = Math.ceil(height)
root.style.setProperty("--session-turn-sticky-height", `${next}px`)
}
function duration() {
const msg = message()
if (!msg) return ""
const completed = lastAssistantMessage()?.time.completed
const from = DateTime.fromMillis(msg.time.created)
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
const interval = Interval.fromDateTimes(from, to)
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
const locale = i18n.locale()
const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({
notation: "compact",
unitDisplay: "narrow",
compactDisplay: "short",
showZeros: false,
})
return locale.startsWith("zh") ? human.replaceAll("、", "") : human
}
const assistantVisible = createMemo(() =>
assistantMessages().reduce((count, message) => {
const parts = list(data.store.part?.[message.id], emptyParts)
return count + parts.filter(visible).length
}, 0),
)
const autoScroll = createAutoScroll({
working,
onUserInteracted: props.onUserInteracted,
overflowAnchor: "auto",
})
createResizeObserver(
() => stickyRef(),
({ height }) => {
updateStickyHeight(height)
},
)
createEffect(() => {
const root = rootRef()
if (!root) return
const sticky = stickyRef()
if (!sticky) {
root.style.setProperty("--session-turn-sticky-height", "0px")
return
}
updateStickyHeight(sticky.getBoundingClientRect().height)
})
const [store, setStore] = createStore({
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
})
createEffect(() => {
const r = retry()
if (!r) {
setStore("retrySeconds", 0)
return
}
const updateSeconds = () => {
const next = r.next
if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
}
updateSeconds()
const timer = setInterval(updateSeconds, 1000)
onCleanup(() => clearInterval(timer))
})
let retryLog = ""
createEffect(() => {
const r = retry()
if (!r) return
const key = `${r.attempt}:${r.next}:${r.message}`
if (key === retryLog) return
retryLog = key
console.warn("[session-turn] retry", {
sessionID: props.sessionID,
messageID: props.messageID,
attempt: r.attempt,
next: r.next,
raw: r.message,
parsed: unwrap(r.message),
})
})
let errorLog = ""
createEffect(() => {
const value = error()?.data?.message
if (value === undefined || value === null) return
const raw = typeof value === "string" ? value : String(value)
if (!raw) return
if (raw === errorLog) return
errorLog = raw
console.warn("[session-turn] assistant-error", {
sessionID: props.sessionID,
messageID: props.messageID,
raw,
parsed: unwrap(raw),
})
})
createEffect(() => {
const update = () => {
setStore("duration", duration())
}
update()
// Only keep ticking while the active (in-progress) turn is running.
if (!working()) return
const timer = setInterval(update, 1000)
onCleanup(() => clearInterval(timer))
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === store.status || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 2500) {
setStore("status", newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStore("status", rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 2500 - timeSinceLastChange) as unknown as number
}
})
onCleanup(() => {
if (!statusTimeout) return
clearTimeout(statusTimeout)
overflowAnchor: "dynamic",
})
return (
<div data-component="session-turn" class={props.classes?.root} ref={setRootRef}>
<div data-component="session-turn" class={props.classes?.root}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
@@ -619,185 +236,32 @@ export function SessionTurn(
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<Switch>
<Match when={isShellMode()}>
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
<Show when={attachmentParts().length > 0}>
<div data-slot="session-turn-attachments" aria-live="off">
<Message message={msg()} parts={attachmentParts()} />
</div>
</Show>
<div data-slot="session-turn-sticky" ref={setStickyRef}>
{/* User Message */}
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={stickyParts()} />
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
aria-expanded={props.stepsExpanded}
>
<Switch>
<Match when={working()}>
<Spinner />
</Match>
<Match when={!props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-slot="session-turn-trigger-icon"
>
<path
d="M8.125 1.875H1.875L5 8.125L8.125 1.875Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
<Match when={props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-icon-base"
>
<path
d="M8.125 8.125H1.875L5 1.875L8.125 8.125Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
</Switch>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
const msg = unwrap(r.message)
return msg.length > 60 ? msg.slice(0, 60) + "..." : msg
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· {i18n.t("ui.sessionTurn.retry.retrying")}
{store.retrySeconds > 0
? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds })
: ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>
<span data-slot="session-turn-status-text">
{store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")}
</span>
</Match>
<Match when={props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.hide")}</span>
</Match>
<Match when={!props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.show")}</span>
</Match>
</Switch>
<span aria-hidden="true">·</span>
<span aria-live="off">{store.duration}</span>
</Button>
</div>
</Show>
</div>
{/* Response */}
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner" aria-hidden={working()}>
<For each={assistantMessages()}>
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
hidden={hidden}
/>
)}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
</Show>
<Show when={!props.stepsExpanded && answeredQuestionParts().length > 0}>
<div data-slot="session-turn-answered-question-parts">
<For each={answeredQuestionParts()}>
{({ part, message }) => <Part part={part} message={message} />}
</For>
</div>
</Show>
{/* Response */}
<div class="sr-only" aria-live="polite">
{!working() && response() ? response() : ""}
</div>
<Show when={!working() && response()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<div data-slot="session-turn-summary-title-row">
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
<Show when={response()}>
<div data-slot="session-turn-response-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
handleCopy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</Show>
</div>
<div data-slot="session-turn-response">
<Markdown
data-slot="session-turn-markdown"
data-diffs={hasDiffs()}
text={response() ?? ""}
cacheKey={responsePartId()}
/>
</div>
</div>
</div>
</Show>
<Show when={error() && !props.stepsExpanded}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</Match>
</Switch>
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={parts()} />
</div>
<Show when={working() && assistantVisible() === 0 && !error()}>
<div data-slot="session-turn-thinking">
<span>Thinking</span>
<Spinner style={{ width: "16px" }} />
</div>
</Show>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<For each={assistantMessages()}>
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
showAssistantCopyPartID={showAssistantCopyPartID()}
/>
)}
</For>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
)}
</Show>

View File

@@ -0,0 +1,43 @@
[data-component="text-shimmer"] {
--text-shimmer-step: 45ms;
--text-shimmer-duration: 1200ms;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
white-space: pre;
color: inherit;
}
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] {
animation-name: text-shimmer-char;
animation-duration: var(--text-shimmer-duration);
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index));
}
@keyframes text-shimmer-char {
0%,
100% {
color: var(--text-weaker);
}
30% {
color: var(--text-weak);
}
55% {
color: var(--text-base);
}
75% {
color: var(--text-strong);
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
animation: none !important;
color: inherit;
}
}

View File

@@ -0,0 +1,36 @@
import { For, createMemo, type ValidComponent } from "solid-js"
import { Dynamic } from "solid-js/web"
export const TextShimmer = <T extends ValidComponent = "span">(props: {
text: string
class?: string
as?: T
active?: boolean
stepMs?: number
durationMs?: number
}) => {
const chars = createMemo(() => Array.from(props.text))
const active = () => props.active ?? true
return (
<Dynamic
component={props.as || "span"}
data-component="text-shimmer"
data-active={active()}
class={props.class}
aria-label={props.text}
style={{
"--text-shimmer-step": `${props.stepMs ?? 45}ms`,
"--text-shimmer-duration": `${props.durationMs ?? 1200}ms`,
}}
>
<For each={chars()}>
{(char, index) => (
<span data-slot="text-shimmer-char" aria-hidden="true" style={{ "--text-shimmer-index": `${index()}` }}>
{char}
</span>
)}
</For>
</Dynamic>
)
}

View File

@@ -50,8 +50,6 @@ export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
export type SyncSessionFn = (sessionID: string) => void | Promise<void>
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -62,7 +60,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
onSyncSession?: SyncSessionFn
}) => {
return {
get store() {
@@ -76,7 +73,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
syncSession: props.onSyncSession,
}
},
})

View File

@@ -89,7 +89,7 @@ export const dict = {
"ui.message.expand": "Expand message",
"ui.message.collapse": "Collapse message",
"ui.message.copy": "Copy",
"ui.message.copied": "Copied!",
"ui.message.copied": "Copied",
"ui.message.attachment.alt": "attachment",
"ui.patch.action.deleted": "Deleted",

View File

@@ -48,6 +48,7 @@
@import "../components/sticky-accordion-header.css" layer(components);
@import "../components/tabs.css" layer(components);
@import "../components/tag.css" layer(components);
@import "../components/text-shimmer.css" layer(components);
@import "../components/toast.css" layer(components);
@import "../components/tooltip.css" layer(components);
@import "../components/typewriter.css" layer(components);

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.61",
"version": "1.1.64",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.61",
"version": "1.1.64",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -1478,6 +1478,39 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon
---
### STACKIT
STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure.
1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project.
:::tip
You need a STACKIT customer account, user account, and project before creating auth tokens.
:::
2. Run the `/connect` command and search for **STACKIT**.
```txt
/connect
```
3. Enter your STACKIT AI Model Serving auth token.
```txt
┌ API key
└ enter
```
4. Run the `/models` command to select from available models like _Qwen3-VL 235B_ or _Llama 3.3 70B_.
```txt
/models
```
---
### OVHcloud AI Endpoints
1. Head over to the [OVHcloud panel](https://ovh.com/manager). Navigate to the `Public Cloud` section, `AI & Machine Learning` > `AI Endpoints` and in `API Keys` tab, click **Create a new API key**.

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.1.61",
"version": "1.1.64",
"publisher": "sst-dev",
"repository": {
"type": "git",