mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-15 05:15:20 +00:00
Compare commits
57 Commits
v1.1.64
...
opencode/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3347b591ce | ||
|
|
5bbc571276 | ||
|
|
5cc461578b | ||
|
|
402cf49164 | ||
|
|
afe9763668 | ||
|
|
e22426303a | ||
|
|
f148ec687e | ||
|
|
27aa01d1b7 | ||
|
|
fe4f2b1b23 | ||
|
|
14b65ee985 | ||
|
|
e1b90d0340 | ||
|
|
559b2275c0 | ||
|
|
20545c98cc | ||
|
|
2d13bda31a | ||
|
|
79ee589099 | ||
|
|
8d53d22c36 | ||
|
|
ddc3032b72 | ||
|
|
402fc9eed9 | ||
|
|
f554e5ee7a | ||
|
|
a21a441409 | ||
|
|
c6500328ca | ||
|
|
ff1077b3ad | ||
|
|
90e248f6a0 | ||
|
|
61455e7d31 | ||
|
|
dcce83aaa0 | ||
|
|
79fc63709c | ||
|
|
6532b4fb76 | ||
|
|
0e7627f637 | ||
|
|
12a80c4000 | ||
|
|
713cc7339e | ||
|
|
4da246ea01 | ||
|
|
cbf9641642 | ||
|
|
8e69ff0fe7 | ||
|
|
490967208c | ||
|
|
2fc3bfefc0 | ||
|
|
f10787ef74 | ||
|
|
bda07f7d8f | ||
|
|
dec46fba39 | ||
|
|
383a0fb896 | ||
|
|
532d7e9d80 | ||
|
|
881634f2e7 | ||
|
|
99e7521289 | ||
|
|
5a663fbd23 | ||
|
|
1760e4fb6e | ||
|
|
c0eb553a94 | ||
|
|
c807319f31 | ||
|
|
f16466c996 | ||
|
|
bc501167b2 | ||
|
|
536d3f73af | ||
|
|
594341d8f8 | ||
|
|
3ba0265ad8 | ||
|
|
e448e77c90 | ||
|
|
1c80f92281 | ||
|
|
b10885b557 | ||
|
|
c31e678391 | ||
|
|
d238344931 | ||
|
|
a74d6c3c23 |
36
bun.lock
36
bun.lock
@@ -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
16
install
@@ -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"
|
||||
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.61",
|
||||
"version": "1.1.64",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
85
packages/app/src/components/session-todo-dock.tsx
Normal file
85
packages/app/src/components/session-todo-dock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
33
packages/app/src/utils/terminal-writer.test.ts
Normal file
33
packages/app/src/utils/terminal-writer.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
27
packages/app/src/utils/terminal-writer.ts
Normal file
27
packages/app/src/utils/terminal-writer.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.61",
|
||||
"version": "1.1.64",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.61",
|
||||
"version": "1.1.64",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.61",
|
||||
"version": "1.1.64",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
25
packages/opencode/src/cli/cmd/tui/util/selection.ts
Normal file
25
packages/opencode/src/cli/cmd/tui/util/selection.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
54
packages/opencode/test/pty/pty-output-isolation.test.ts
Normal file
54
packages/opencode/test/pty/pty-output-isolation.test.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.61",
|
||||
"version": "1.1.64",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.61",
|
||||
"version": "1.1.64",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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; */
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
43
packages/ui/src/components/text-shimmer.css
Normal file
43
packages/ui/src/components/text-shimmer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
36
packages/ui/src/components/text-shimmer.tsx
Normal file
36
packages/ui/src/components/text-shimmer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.61",
|
||||
"version": "1.1.64",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user