Compare commits

...
Author SHA1 Message Date
David Hill 4871a3b418 Revert "tweak(ui): spacing"
This reverts commit bd9ca72f3c.
2026-04-08 17:27:16 +01:00
David Hill bd9ca72f3c tweak(ui): spacing 2026-04-08 17:24:35 +01:00
Adam a1dbebb828 feat(app): better busy state 2026-04-06 11:02:19 -05:00
Adam e59ccfa39d chore: cleanup 2026-04-06 11:02:19 -05:00
Adam 5bf12ab783 chore: cleanup subagent header 2026-04-06 11:02:19 -05:00
Adam cf5ced6a18 fix(app): remove message nav popover 2026-04-06 11:02:19 -05:00
Adam c630841e8a chore: update test 2026-04-06 11:02:18 -05:00
Adam e7706670a9 feat(app): better subagent experience 2026-04-06 11:02:18 -05:00
MC 965c751522 docs: update Cloudflare provider setup to reflect /connect prompt flow (#20589) 2026-04-06 10:50:24 -05:00
opencode-agent[bot] 24bdd3c9fb chore: generate 2026-04-06 13:51:36 +00:00
Derek Barrera 01f0319192 fix(lsp): MEMORY LEAK: ensure typescript server uses native project config (#19953) 2026-04-06 09:50:36 -04:00
opencode 517e6c9aa4 release: v1.3.17 2026-04-06 07:39:18 +00:00
Luke Parker a4a9ea4ab0 fix(tui): revert kitty keyboard events workaround on windows (#20180) 2026-04-06 07:04:50 +00:00
eaa272ef7f fix: show clear error when Cloudflare provider env vars are missing (#20399)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-06 05:26:04 +00:00
Frank 70b636a360 zen: normalize ipv6 2026-04-06 00:32:55 -04:00
Frank a8fd0159be zen: remove header check 2026-04-05 23:51:37 -04:00
opencode 342436dfc4 release: v1.3.16 2026-04-06 03:44:46 +00:00
Luke Parker 77a462c930 fix(tui): default Ctrl+Z to undo on Windows (#21138) 2026-04-06 02:38:35 +00:00
9965d385de fix: pass both 'openai' and 'azure' providerOptions keys for @ai-sdk/azure (#20272)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-06 02:34:53 +00:00
George Harker f0f1e51c5c fix(core): implement proper configOptions for acp (#21134) 2026-04-05 21:29:34 -05:00
Gautier DI FOLCO 4712c18a58 feat(tui): make the mouse disablable (#6824, #7926) (#13748) 2026-04-05 21:14:11 -05:00
opencode-agent[bot] 9e156ea168 chore: update nix node_modules hashes 2026-04-06 01:18:03 +00:00
Luke Parker 68f4aa220e fix(plugin): parse package specifiers with npm-package-arg and sanitize win32 cache paths (#21135) 2026-04-06 00:26:40 +00:00
68 changed files with 1461 additions and 670 deletions
+18 -16
View File
@@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -80,7 +80,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -114,7 +114,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -141,7 +141,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -165,7 +165,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -189,7 +189,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -222,7 +222,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -254,7 +254,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -283,7 +283,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -299,7 +299,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.3.15", "version": "1.3.17",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -371,6 +371,7 @@
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"npm-package-arg": "13.0.2",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.1", "opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1", "opencode-poe-auth": "0.0.1",
@@ -412,6 +413,7 @@
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/cross-spawn": "catalog:", "@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3", "@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
@@ -428,7 +430,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -462,7 +464,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -477,7 +479,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -512,7 +514,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -560,7 +562,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -571,7 +573,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=", "x86_64-linux": "sha256-LRhPPrOKCGUSCEWTpAxPdWKTKVNkg82WrvD25cP3jts=",
"aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=", "aarch64-linux": "sha256-sbNxkil47n+B7v6ds5EYFybLytXUyRlu0Cpka0ZmDx4=",
"aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=", "aarch64-darwin": "sha256-5+99gtpIHGygMW3VBAexNhmaORgI8LCxPk/Gf1fW/ds=",
"x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI=" "x86_64-darwin": "sha256-LqnvZGGnQaRxIoowOr5gf6lFgDhbgQhVPiAcRTtU6fE="
} }
} }
@@ -1,7 +1,6 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock" import { inputMatch } from "../prompt/mock"
import { promptSelector } from "../selectors"
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
@@ -30,15 +29,33 @@ test("task tool child-session link does not trigger stale show errors", async ({
await project.gotoSession(session.id) await project.gotoSession(session.id)
const link = page const header = page.locator("[data-session-title]")
.locator("a.subagent-link") await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 })
const card = page
.locator('[data-component="task-tool-card"]')
.filter({ hasText: /open child session/i }) .filter({ hasText: /open child session/i })
.first() .first()
await expect(link).toBeVisible({ timeout: 30_000 }) await expect(card).toBeVisible({ timeout: 30_000 })
await link.click() await card.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 }) await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title)
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description)
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
await expect
.poll(
() =>
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
left: getComputedStyle(el).paddingLeft,
right: getComputedStyle(el).paddingRight,
})),
{ timeout: 30_000 },
)
.toEqual({ left: "8px", right: "8px" })
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
}) })
} finally { } finally {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.15", "version": "1.3.17",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+2
View File
@@ -238,6 +238,8 @@ export const dict = {
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt", "prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc to exit", "prompt.mode.shell.exit": "esc to exit",
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
"session.child.backToParent": "Back to main session.",
"prompt.example.1": "Fix a TODO in the codebase", "prompt.example.1": "Fix a TODO in the codebase",
"prompt.example.2": "What is the tech stack of this project?", "prompt.example.2": "What is the tech stack of this project?",
+40
View File
@@ -1,6 +1,46 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@layer components { @layer components {
@keyframes session-progress-whip {
0% {
clip-path: inset(0 100% 0 0 round 999px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
48% {
clip-path: inset(0 0 0 0 round 999px);
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
100% {
clip-path: inset(0 0 0 100% round 999px);
}
}
[data-component="session-progress"] {
position: absolute;
inset: 0 0 auto;
height: 2px;
overflow: hidden;
pointer-events: none;
opacity: 1;
transition: opacity 220ms ease-out;
}
[data-component="session-progress"][data-state="hiding"] {
opacity: 0;
}
[data-component="session-progress-bar"] {
width: 100%;
height: 100%;
border-radius: 999px;
background: var(--session-progress-color);
clip-path: inset(0 100% 0 0 round 999px);
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
will-change: clip-path;
}
[data-component="getting-started"] { [data-component="getting-started"] {
container-type: inline-size; container-type: inline-size;
container-name: getting-started; container-name: getting-started;
-17
View File
@@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined, nav: undefined as HTMLElement | undefined,
@@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) {
onActivate: (directory) => { onActivate: (directory) => {
globalSync.child(directory) globalSync.child(directory)
setState("hoverProject", directory) setState("hoverProject", directory)
setState("hoverSession", undefined)
}, },
}) })
@@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) {
aim.reset() aim.reset()
} }
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => { const disarm = () => {
if (navLeave.current === undefined) return if (navLeave.current === undefined) return
@@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) {
const reset = () => { const reset = () => {
disarm() disarm()
setState("hoverSession", undefined)
setHoverProject(undefined) setHoverProject(undefined)
} }
@@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) {
navLeave.current = window.setTimeout(() => { navLeave.current = window.setTimeout(() => {
navLeave.current = undefined navLeave.current = undefined
setHoverProject(undefined) setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300) }, 300)
} }
@@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) {
navList: currentSessions, navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering, sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
@@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) {
sidebarOpened: () => layout.sidebar.opened(), sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering, sidebarHovering,
hoverProject: () => state.hoverProject, hoverProject: () => state.hoverProject,
nav: () => state.nav,
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree), onProjectFocus: (worktree) => aim.activate(worktree),
@@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) {
sessionProps: { sessionProps: {
navList: currentSessions, navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
}, },
setHoverSession,
} }
const SidebarPanel = (panelProps: { const SidebarPanel = (panelProps: {
@@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) {
const project = panelProps.project const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0) const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => { const projectName = createMemo(() => {
const item = project() const item = project()
@@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) {
project={project()!} project={project()!}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
</div> </div>
</> </>
@@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) {
project={project()!} project={project()!}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
)} )}
</For> </For>
@@ -8,6 +8,7 @@ import {
} from "./deep-links" } from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { import {
childSessionOnPath,
displayName, displayName,
effectiveWorkspaceOrder, effectiveWorkspaceOrder,
errorMessage, errorMessage,
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("root") expect(result?.id).toBe("root")
}) })
test("finds the direct child on the active session path", () => {
const list = [
session({ id: "root", directory: "/workspace" }),
session({ id: "child", directory: "/workspace", parentID: "root" }),
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
]
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
})
test("formats fallback project display name", () => { test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app") expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+10 -11
View File
@@ -46,18 +46,17 @@ export function hasProjectPermissions<T>(
return Object.values(request ?? {}).some((list) => list?.some(include)) return Object.values(request ?? {}).some((list) => list?.some(include))
} }
export const childMapByParent = (sessions: Session[] | undefined) => { export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
const map = new Map<string, string[]>() if (!activeID || activeID === rootID) return
for (const session of sessions ?? []) { const map = new Map((sessions ?? []).map((session) => [session.id, session]))
if (!session.parentID) continue let id = activeID
const existing = map.get(session.parentID)
if (existing) { while (id) {
existing.push(session.id) const session = map.get(id)
continue if (!session?.parentID) return
} if (session.parentID === rootID) return session
map.set(session.parentID, [session.id]) id = session.parentID
} }
return map
} }
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
+92 -204
View File
@@ -1,15 +1,12 @@
import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -18,7 +15,7 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title" import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers" import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -39,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
) )
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return ( return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip"> <div class="size-full rounded overflow-clip">
@@ -73,13 +71,10 @@ export type SessionItemProps = {
slug: string slug: string
mobile?: boolean mobile?: boolean
dense?: boolean dense?: boolean
popover?: boolean showTooltip?: boolean
children: Map<string, string[]> showChild?: boolean
level?: number
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
@@ -95,116 +90,52 @@ const SessionRow = (props: {
hasPermissions: Accessor<boolean> hasPermissions: Accessor<boolean>
hasError: Accessor<boolean> hasError: Accessor<boolean>
unseenCount: Accessor<number> unseenCount: Accessor<number>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
warmHover: () => void
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
cancelHoverPrefetch: () => void }): JSX.Element => {
}) => {
const title = () => sessionTitle(props.session.title) const title = () => sessionTitle(props.session.title)
return ( return (
<A <A
href={`/${props.slug}/session/${props.session.id}`} href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress} onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus} onFocus={props.warmFocus}
onClick={() => { onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return if (props.sidebarOpened()) return
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
> >
<div <Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
class="shrink-0 size-6 flex items-center justify-center" <div
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} class="shrink-0 size-6 flex items-center justify-center"
> style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> >
<Match when={props.isWorking()}> <Switch>
<Spinner class="size-[15px]" /> <Match when={props.isWorking()}>
</Match> <Spinner class="size-[15px]" />
<Match when={props.hasPermissions()}> </Match>
<div class="size-1.5 rounded-full bg-surface-warning-strong" /> <Match when={props.hasPermissions()}>
</Match> <div class="size-1.5 rounded-full bg-surface-warning-strong" />
<Match when={props.hasError()}> </Match>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" /> <Match when={props.hasError()}>
</Match> <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
<Match when={props.unseenCount() > 0}> </Match>
<div class="size-1.5 rounded-full bg-text-interactive-base" /> <Match when={props.unseenCount() > 0}>
</Match> <div class="size-1.5 rounded-full bg-text-interactive-base" />
</Switch> </Match>
</div> </Switch>
</div>
</Show>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span> <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
</A> </A>
) )
} }
const SessionHoverPreview = (props: {
mobile?: boolean
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
session: Session
sidebarHovering: Accessor<boolean>
hoverReady: Accessor<boolean>
hoverMessages: Accessor<UserMessage[] | undefined>
language: ReturnType<typeof useLanguage>
isActive: Accessor<boolean>
slug: string
setHoverSession: (id: string | undefined) => void
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => {
let ref: HTMLDivElement | undefined
return (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={
<div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => { export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams() const params = useParams()
const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
@@ -234,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
) )
}) })
const tint = createMemo(() => { const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent) const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
const currentChild = createMemo(() => {
if (!props.showChild) return
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
}) })
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
)
const hoverReady = createMemo(() => hoverMessages() !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const warm = (span: number, priority: "high" | "low") => { const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.() const nav = props.navList?.()
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory) const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
@@ -266,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
} }
} }
const hoverPrefetch = {
current: undefined as ReturnType<typeof setTimeout> | undefined,
}
const cancelHoverPrefetch = () => {
if (hoverPrefetch.current === undefined) return
clearTimeout(hoverPrefetch.current)
hoverPrefetch.current = undefined
}
const scheduleHoverPrefetch = () => {
warm(1, "high")
if (hoverPrefetch.current !== undefined) return
hoverPrefetch.current = setTimeout(() => {
hoverPrefetch.current = undefined
warm(2, "low")
}, 80)
}
onCleanup(cancelHoverPrefetch)
const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? []
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
return text?.text
}
const item = ( const item = (
<SessionRow <SessionRow
session={props.session} session={props.session}
@@ -301,86 +203,74 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
hasError={hasError} hasError={hasError}
unseenCount={unseenCount} unseenCount={unseenCount}
setHoverSession={props.setHoverSession}
clearHoverProjectSoon={props.clearHoverProjectSoon} clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened} sidebarOpened={layout.sidebar.opened}
warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")} warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")} warmFocus={() => warm(2, "high")}
cancelHoverPrefetch={cancelHoverPrefetch}
/> />
) )
return ( return (
<div <>
data-session-id={props.session.id} <div
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors data-session-id={props.session.id}
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
<div class="flex min-w-0 items-center gap-1"> >
<div class="min-w-0 flex-1"> <div class="flex min-w-0 items-center gap-1">
<Show <div class="min-w-0 flex-1">
when={hoverEnabled()} <Show
fallback={ when={!tooltip()}
<Tooltip fallback={
placement={props.mobile ? "bottom" : "right"} <Tooltip
value={sessionTitle(props.session.title)} placement={props.mobile ? "bottom" : "right"}
gutter={10} value={sessionTitle(props.session.title)}
class="min-w-0 w-full" gutter={10}
> class="min-w-0 w-full"
{item} >
</Tooltip> {item}
} </Tooltip>
> }
<SessionHoverPreview >
mobile={props.mobile} {item}
nav={props.nav} </Show>
hoverSession={props.hoverSession} </div>
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) <Show when={!props.level}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}} }}
trigger={item} >
/> <Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</Show> </Show>
</div> </div>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</div> </div>
</div> <Show when={currentChild()}>
{(child) => (
<div class="w-full">
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
</div>
)}
</Show>
</>
) )
} }
@@ -390,7 +280,6 @@ export const NewSessionItem = (props: {
dense?: boolean dense?: boolean
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
setHoverSession: (id: string | undefined) => void
}): JSX.Element => { }): JSX.Element => {
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
@@ -400,9 +289,8 @@ export const NewSessionItem = (props: {
<A <A
href={`/${props.slug}/session`} href={`/${props.slug}/session`}
end end
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => { onClick={() => {
props.setHoverSession(undefined)
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { childMapByParent, displayName, sortedRootSessions } from "./helpers" import { displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
@@ -19,7 +19,6 @@ export type ProjectSidebarContext = {
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined> hoverProject: Accessor<string | undefined>
nav: Accessor<HTMLElement | undefined>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
@@ -32,8 +31,7 @@ export type ProjectSidebarContext = {
workspacesEnabled: (project: LocalProject) => boolean workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[] workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover"> sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
setHoverSession: (id: string | undefined) => void
} }
export const ProjectDragOverlay = (props: { export const ProjectDragOverlay = (props: {
@@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: {
const ProjectTile = (props: { const ProjectTile = (props: {
project: LocalProject project: LocalProject
mobile?: boolean mobile?: boolean
nav: Accessor<HTMLElement | undefined>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
selected: Accessor<boolean> selected: Accessor<boolean>
active: Accessor<boolean> active: Accessor<boolean>
@@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: {
workspaces: Accessor<string[]> workspaces: Accessor<string[]>
label: (directory: string) => string label: (directory: string) => string
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>> projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
ctx: ProjectSidebarContext ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
@@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: {
list={props.projectSessions()} list={props.projectSessions()}
slug={base64Encode(props.project.worktree)} slug={base64Encode(props.project.worktree)}
dense dense
showTooltip
mobile={props.mobile} mobile={props.mobile}
popover={false}
children={props.projectChildren()}
/> />
)} )}
</For> </For>
@@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: {
<For each={props.workspaces()}> <For each={props.workspaces()}>
{(directory) => { {(directory) => {
const sessions = createMemo(() => props.workspaceSessions(directory)) const sessions = createMemo(() => props.workspaceSessions(directory))
const children = createMemo(() => props.workspaceChildren(directory))
return ( return (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
@@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: {
list={sessions()} list={sessions()}
slug={base64Encode(directory)} slug={base64Encode(directory)}
dense dense
showTooltip
mobile={props.mobile} mobile={props.mobile}
popover={false}
children={children()}
/> />
)} )}
</For> </For>
@@ -310,20 +302,14 @@ export const SortableProject = (props: {
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
const workspaceSessions = (directory: string) => { const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow()) return sortedRootSessions(data, props.sortNow())
} }
const workspaceChildren = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
return childMapByParent(data.session)
}
const tile = () => ( const tile = () => (
<ProjectTile <ProjectTile
project={props.project} project={props.project}
mobile={props.mobile} mobile={props.mobile}
nav={props.ctx.nav}
sidebarHovering={props.ctx.sidebarHovering} sidebarHovering={props.ctx.sidebarHovering}
selected={selected} selected={selected}
active={active} active={active}
@@ -360,7 +346,6 @@ export const SortableProject = (props: {
if (state.menu) return if (state.menu) return
if (value && state.suppressHover) return if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value) props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined)
}} }}
> >
<ProjectPreviewPanel <ProjectPreviewPanel
@@ -371,9 +356,7 @@ export const SortableProject = (props: {
workspaces={workspaces} workspaces={workspaces}
label={label} label={label}
projectSessions={projectSessions} projectSessions={projectSessions}
projectChildren={projectChildren}
workspaceSessions={workspaceSessions} workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
ctx={props.ctx} ctx={props.ctx}
language={language} language={language}
/> />
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers" import { sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = {
navList: Accessor<Session[]> navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
@@ -152,7 +149,6 @@ const WorkspaceActions = (props: {
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string root: string
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
navigateToNewSession: () => void navigateToNewSession: () => void
}): JSX.Element => ( }): JSX.Element => (
@@ -226,7 +222,6 @@ const WorkspaceActions = (props: {
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.setHoverSession(undefined)
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
props.navigateToNewSession() props.navigateToNewSession()
}} }}
@@ -239,12 +234,10 @@ const WorkspaceActions = (props: {
const WorkspaceSessionList = (props: { const WorkspaceSessionList = (props: {
slug: Accessor<string> slug: Accessor<string>
mobile?: boolean mobile?: boolean
popover?: boolean
ctx: WorkspaceSidebarContext ctx: WorkspaceSidebarContext
showNew: Accessor<boolean> showNew: Accessor<boolean>
loading: Accessor<boolean> loading: Accessor<boolean>
sessions: Accessor<Session[]> sessions: Accessor<Session[]>
children: Accessor<Map<string, string[]>>
hasMore: Accessor<boolean> hasMore: Accessor<boolean>
loadMore: () => Promise<void> loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
@@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: {
mobile={props.mobile} mobile={props.mobile}
sidebarExpanded={props.ctx.sidebarExpanded} sidebarExpanded={props.ctx.sidebarExpanded}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
setHoverSession={props.ctx.setHoverSession}
/> />
</Show> </Show>
<Show when={props.loading()}> <Show when={props.loading()}>
@@ -270,13 +262,8 @@ const WorkspaceSessionList = (props: {
navList={props.ctx.navList} navList={props.ctx.navList}
slug={props.slug()} slug={props.slug()}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover} showChild
children={props.children()}
sidebarExpanded={props.ctx.sidebarExpanded} sidebarExpanded={props.ctx.sidebarExpanded}
sidebarHovering={props.ctx.sidebarHovering}
nav={props.ctx.nav}
hoverSession={props.ctx.hoverSession}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession} prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession} archiveSession={props.ctx.archiveSession}
@@ -307,7 +294,6 @@ export const SortableWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
@@ -321,7 +307,6 @@ export const SortableWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.directory)) const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree) const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => { const workspaceValue = createMemo(() => {
@@ -428,7 +413,6 @@ export const SortableWorkspace = (props: {
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree} root={props.project.worktree}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
navigateToNewSession={() => navigate(`/${slug()}/session`)} navigateToNewSession={() => navigate(`/${slug()}/session`)}
/> />
@@ -440,12 +424,10 @@ export const SortableWorkspace = (props: {
<WorkspaceSessionList <WorkspaceSessionList
slug={slug} slug={slug}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
ctx={props.ctx} ctx={props.ctx}
showNew={showNew} showNew={showNew}
loading={loading} loading={loading}
sessions={sessions} sessions={sessions}
children={children}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
language={language} language={language}
@@ -461,7 +443,6 @@ export const LocalWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
@@ -471,7 +452,6 @@ export const LocalWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.project.worktree)) const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const children = createMemo(() => childMapByParent(workspace().store.session))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0) const count = createMemo(() => sessions()?.length ?? 0)
const loading = createMemo(() => !booted() && count() === 0) const loading = createMemo(() => !booted() && count() === 0)
@@ -489,12 +469,10 @@ export const LocalWorkspace = (props: {
<WorkspaceSessionList <WorkspaceSessionList
slug={slug} slug={slug}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
ctx={props.ctx} ctx={props.ctx}
showNew={() => false} showNew={() => false}
loading={loading} loading={loading}
sessions={sessions} sessions={sessions}
children={children}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
language={language} language={language}
+10 -4
View File
@@ -429,6 +429,7 @@ export default function Page() {
} }
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0) const hasSessionReview = createMemo(() => sessionCount() > 0)
@@ -1058,7 +1059,7 @@ export default function Page() {
} }
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked()) return if (composer.blocked() || isChildSession()) return
inputRef?.focus() inputRef?.focus()
} }
} }
@@ -1127,7 +1128,10 @@ export default function Page() {
setFileTreeTab("all") setFileTreeTab("all")
} }
const focusInput = () => inputRef?.focus() const focusInput = () => {
if (isChildSession()) return
inputRef?.focus()
}
useSessionCommands({ useSessionCommands({
navigateMessageByOffset, navigateMessageByOffset,
@@ -1658,7 +1662,7 @@ export default function Page() {
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
const id = params.id const id = params.id
if (!id) return false if (!id) return false
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
}) })
const followupText = (item: FollowupDraft) => { const followupText = (item: FollowupDraft) => {
@@ -1690,6 +1694,7 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve()
@@ -1820,6 +1825,7 @@ export default function Page() {
if (followupBusy(sessionID)) return if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (isChildSession()) return
if (composer.blocked()) return if (composer.blocked()) return
if (busy(sessionID)) return if (busy(sessionID)) return
@@ -2001,7 +2007,7 @@ export default function Page() {
}} }}
onResponseSubmit={resumeScroll} onResponseSubmit={resumeScroll}
followup={ followup={
params.id params.id && !isChildSession()
? { ? {
queue: queueEnabled, queue: queueEnabled,
items: followupDock(), items: followupDock(),
@@ -1,9 +1,11 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
@@ -43,11 +45,17 @@ export function SessionComposerRegion(props: {
} }
setPromptDockRef: (el: HTMLDivElement) => void setPromptDockRef: (el: HTMLDivElement) => void
}) { }) {
const navigate = useNavigate()
const prompt = usePrompt() const prompt = usePrompt()
const language = useLanguage() const language = useLanguage()
const route = useSessionKey() const route = useSessionKey()
const sync = useSync()
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt) const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
const parentID = createMemo(() => info()?.parentID)
const child = createMemo(() => !!parentID())
const showComposer = createMemo(() => !props.state.blocked() || child())
const previewPrompt = () => const previewPrompt = () =>
prompt prompt
@@ -113,6 +121,12 @@ export function SessionComposerRegion(props: {
const lift = createMemo(() => (rolled() ? 18 : 36 * value())) const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height)) const full = createMemo(() => Math.max(78, store.height))
const openParent = () => {
const id = parentID()
if (!id) return
navigate(`/${route.params.dir}/session/${id}`)
}
createEffect(() => { createEffect(() => {
const el = store.body const el = store.body
if (!el) return if (!el) return
@@ -156,7 +170,7 @@ export function SessionComposerRegion(props: {
)} )}
</Show> </Show>
<Show when={!props.state.blocked()}> <Show when={showComposer()}>
<Show <Show
when={prompt.ready()} when={prompt.ready()}
fallback={ fallback={
@@ -232,17 +246,40 @@ export function SessionComposerRegion(props: {
onEdit={props.followup!.onEdit} onEdit={props.followup!.onEdit}
/> />
</Show> </Show>
<PromptInput <Show
ref={props.inputRef} when={child()}
newSessionWorktree={props.newSessionWorktree} fallback={
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} <Show when={!props.state.blocked()}>
edit={props.followup?.edit} <PromptInput
onEditLoaded={props.followup?.onEditLoaded} ref={props.inputRef}
shouldQueue={props.followup?.queue} newSessionWorktree={props.newSessionWorktree}
onQueue={props.followup?.onQueue} onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onAbort={props.followup?.onAbort} edit={props.followup?.edit}
onSubmit={props.onSubmit} onEditLoaded={props.followup?.onEditLoaded}
/> shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
/>
</Show>
}
>
<div
ref={props.inputRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</div>
</Show>
</div> </div>
</Show> </Show>
</Show> </Show>
@@ -21,6 +21,7 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
@@ -68,6 +69,16 @@ const messageComments = (parts: Part[]): MessageComment[] =>
] ]
}) })
const taskDescription = (part: Part, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return
const value = part.state.input?.description
if (typeof value === "string" && value) return value
}
const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]") const nested = current?.closest("[data-scrollable]")
@@ -295,6 +306,32 @@ export function MessageTimeline(props: {
const shareUrl = createMemo(() => info()?.share?.url) const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID) const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
if (!id) return
return sync.session.get(id)
})
const parentMessages = createMemo(() => {
const id = parentID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
return parentMessages()
.flatMap((message) => sync.data.part[message.id] ?? [])
.map((part) => taskDescription(part, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
if (!parentID()) return titleLabel() ?? ""
if (childTaskDescription()) return childTaskDescription()
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID())) const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 } const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({ const staging = createTimelineStaging({
@@ -317,8 +354,20 @@ export function MessageTimeline(props: {
open: false, open: false,
dismiss: null as "escape" | "outside" | null, dismiss: null as "escape" | "outside" | null,
}) })
const [bar, setBar] = createStore({
ms: pace(640),
})
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
createResizeObserver(
() => head,
() => {
if (!head || head.clientWidth <= 0) return
setBar("ms", pace(head.clientWidth))
},
)
const viewShare = () => { const viewShare = () => {
const url = shareUrl() const url = shareUrl()
@@ -398,8 +447,20 @@ export function MessageTimeline(props: {
), ),
) )
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description) return
if (sync.data.message[id] !== undefined) return
void sync.session.sync(id)
},
{ defer: true },
),
)
const openTitleEditor = () => { const openTitleEditor = () => {
if (!sessionID()) return if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" }) setTitle({ editing: true, draft: titleLabel() ?? "" })
requestAnimationFrame(() => { requestAnimationFrame(() => {
titleRef?.focus() titleRef?.focus()
@@ -638,27 +699,53 @@ export function MessageTimeline(props: {
<div ref={props.setContentRef} class="min-w-0 w-full"> <div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}> <Show when={showHeader()}>
<div <div
ref={(el) => {
head = el
setBar("ms", pace(el.clientWidth))
}}
data-session-title data-session-title
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
relative: true,
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true, "pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<Show when={workingStatus() !== "hidden"}>
<div
data-component="session-progress"
data-state={workingStatus()}
aria-hidden="true"
style={{
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
"--session-progress-ms": `${bar.ms}ms`,
}}
>
<div data-component="session-progress-bar" />
</div>
</Show>
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<div class="flex items-center min-w-0 grow-1"> <div class="flex items-center min-w-0 grow-1">
<Show when={parentID()}>
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
onClick={navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="px-2 text-14-medium text-text-weak"
aria-hidden="true"
>
/
</span>
</Show>
<div <div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ style={{
@@ -676,15 +763,16 @@ export function MessageTimeline(props: {
</div> </div>
</Show> </Show>
</div> </div>
<Show when={titleLabel() || title.editing}> <Show when={childTitle() || title.editing}>
<Show <Show
when={title.editing} when={title.editing}
fallback={ fallback={
<h1 <h1
data-slot="session-title-child"
class="text-14-medium text-text-strong truncate grow-1 min-w-0" class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor} onDblClick={openTitleEditor}
> >
{titleLabel()} {childTitle()}
</h1> </h1>
} }
> >
@@ -692,6 +780,7 @@ export function MessageTimeline(props: {
ref={(el) => { ref={(el) => {
titleRef = el titleRef = el
}} }}
data-slot="session-title-child"
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
@@ -719,177 +808,179 @@ export function MessageTimeline(props: {
{(id) => ( {(id) => (
<div class="shrink-0 flex items-center gap-3"> <div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" /> <SessionContextUsage placement="bottom" />
<DropdownMenu <Show when={!parentID()}>
gutter={4} <DropdownMenu
placement="bottom-end" gutter={4}
open={title.menuOpen} placement="bottom-end"
onOpenChange={(open) => { open={title.menuOpen}
setTitle("menuOpen", open) onOpenChange={(open) => {
if (open) return setTitle("menuOpen", open)
}} if (open) return
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}} }}
aria-label={language.t("common.moreOptions")} >
aria-expanded={title.menuOpen || share.open || title.pendingShare} <DropdownMenu.Trigger
ref={(el: HTMLButtonElement) => { as={IconButton}
more = el icon="dot-grid"
}} variant="ghost"
/> class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
<DropdownMenu.Portal> classList={{
<DropdownMenu.Content "bg-surface-base-active": share.open || title.pendingShare,
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> aria-label={language.t("common.moreOptions")}
<DropdownMenu.Item aria-expanded={title.menuOpen || share.open || title.pendingShare}
onSelect={() => { ref={(el: HTMLButtonElement) => {
setTitle("pendingRename", true) more = el
setTitle("menuOpen", false) }}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false }) setTitle("pendingRename", true)
setTitle("menuOpen", false)
}} }}
> >
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> <Show when={shareEnabled()}>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}> <DropdownMenu.Item
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> onSelect={() => {
</DropdownMenu.Item> setTitle({ pendingShare: true, menuOpen: false })
<DropdownMenu.Separator /> }}
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
>
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
> >
<div class="flex flex-col gap-2"> <DropdownMenu.ItemLabel>
<TextField {language.t("session.share.action.share")}
value={shareUrl() ?? ""} </DropdownMenu.ItemLabel>
readOnly </DropdownMenu.Item>
copyable </Show>
copyKind="link" <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
tabIndex={-1} <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
class="w-full" </DropdownMenu.Item>
/> <DropdownMenu.Separator />
<div class="grid grid-cols-2 gap-2"> <DropdownMenu.Item
<Button onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
size="large" >
variant="secondary" <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
class="w-full shadow-none border border-border-weak-base" </DropdownMenu.Item>
onClick={unshareSession} </DropdownMenu.Content>
disabled={unshareMutation.isPending} </DropdownMenu.Portal>
> </DropdownMenu>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing") <KobaltePopover
: language.t("session.share.action.unpublish")} open={share.open}
</Button> anchorRef={() => more}
placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button <Button
size="large" size="large"
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={viewShare} onClick={shareSession}
disabled={unshareMutation.isPending} disabled={shareMutation.isPending}
> >
{language.t("session.share.action.view")} {shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button> </Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div> </div>
</div> </Show>
</Show> </div>
</div> </div>
</div> </KobaltePopover.Content>
</KobaltePopover.Content> </KobaltePopover.Portal>
</KobaltePopover.Portal> </KobaltePopover>
</KobaltePopover> </Show>
</div> </div>
)} )}
</Show> </Show>
+22 -1
View File
@@ -5,9 +5,30 @@ const defaults: Record<string, string> = {
plan: "var(--icon-agent-plan-base)", plan: "var(--icon-agent-plan-base)",
} }
const palette = [
"var(--icon-agent-ask-base)",
"var(--icon-agent-build-base)",
"var(--icon-agent-docs-base)",
"var(--icon-agent-plan-base)",
"var(--syntax-info)",
"var(--syntax-success)",
"var(--syntax-warning)",
"var(--syntax-property)",
"var(--syntax-constant)",
"var(--text-diff-add-base)",
"var(--text-diff-delete-base)",
"var(--icon-warning-base)",
]
function tone(name: string) {
let hash = 0
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
return palette[hash % palette.length]
}
export function agentColor(name: string, custom?: string) { export function agentColor(name: string, custom?: string) {
if (custom) return custom if (custom) return custom
return defaults[name] ?? defaults[name.toLowerCase()] return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase())
} }
export function messageAgentColor( export function messageAgentColor(
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.15", "version": "1.3.17",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -90,7 +90,8 @@ export async function handler(
const body = await input.request.json() const body = await input.request.json()
const model = opts.parseModel(url, body) const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body) const isStream = opts.parseIsStream(url, body)
const ip = input.request.headers.get("x-real-ip") ?? "" const rawIp = input.request.headers.get("x-real-ip") ?? ""
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
const sessionId = input.request.headers.get("x-opencode-session") ?? "" const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? "" const requestId = input.request.headers.get("x-opencode-request") ?? ""
const projectId = input.request.headers.get("x-opencode-project") ?? "" const projectId = input.request.headers.get("x-opencode-project") ?? ""
@@ -17,9 +17,8 @@ export function createRateLimiter(
const dict = i18n(localeFromRequest(request)) const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits() const limits = Subscription.getFreeLimits()
const headerExists = request.headers.has(limits.checkHeader) const dailyLimit = rateLimit ?? limits.dailyRequests
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests) const isDefaultModel = !rateLimit
const isDefaultModel = headerExists && !rateLimit
const ip = !rawIp.length ? "unknown" : rawIp const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now() const now = Date.now()
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.15", "version": "1.3.17",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -9,8 +9,6 @@ export namespace Subscription {
free: z.object({ free: z.object({
promoTokens: z.number().int(), promoTokens: z.number().int(),
dailyRequests: z.number().int(), dailyRequests: z.number().int(),
checkHeader: z.string(),
fallbackValue: z.number().int(),
}), }),
lite: z.object({ lite: z.object({
rollingLimit: z.number().int(), rollingLimit: z.number().int(),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.15", "version": "1.3.17",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.15", "version": "1.3.17",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.3.15", "version": "1.3.17",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.3.15", "version": "1.3.17",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.15", "version": "1.3.17",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.3.15" version = "1.3.17"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.15", "version": "1.3.17",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.3.15", "version": "1.3.17",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -54,6 +54,7 @@
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/cross-spawn": "catalog:", "@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3", "@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
@@ -135,6 +136,7 @@
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"npm-package-arg": "13.0.2",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.1", "opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1", "opencode-poe-auth": "0.0.1",
+84
View File
@@ -21,6 +21,9 @@ import {
type Role, type Role,
type SessionInfo, type SessionInfo,
type SetSessionModelRequest, type SetSessionModelRequest,
type SessionConfigOption,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModeRequest, type SetSessionModeRequest,
type SetSessionModeResponse, type SetSessionModeResponse,
type ToolCallContent, type ToolCallContent,
@@ -601,6 +604,7 @@ export namespace ACP {
return { return {
sessionId, sessionId,
configOptions: load.configOptions,
models: load.models, models: load.models,
modes: load.modes, modes: load.modes,
_meta: load._meta, _meta: load._meta,
@@ -660,6 +664,11 @@ export namespace ACP {
result.modes.currentModeId = lastUser.agent result.modes.currentModeId = lastUser.agent
this.sessionManager.setMode(sessionId, lastUser.agent) this.sessionManager.setMode(sessionId, lastUser.agent)
} }
result.configOptions = buildConfigOptions({
currentModelId: result.models.currentModelId,
availableModels: result.models.availableModels,
modes: result.modes,
})
} }
for (const msg of messages ?? []) { for (const msg of messages ?? []) {
@@ -1266,6 +1275,11 @@ export namespace ACP {
availableModels, availableModels,
}, },
modes, modes,
configOptions: buildConfigOptions({
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
availableModels,
modes,
}),
_meta: buildVariantMeta({ _meta: buildVariantMeta({
model, model,
variant: this.sessionManager.getVariant(sessionId), variant: this.sessionManager.getVariant(sessionId),
@@ -1305,6 +1319,44 @@ export namespace ACP {
this.sessionManager.setMode(params.sessionId, params.modeId) this.sessionManager.setMode(params.sessionId, params.modeId)
} }
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
const session = this.sessionManager.get(params.sessionId)
const providers = await this.sdk.config
.providers({ directory: session.cwd }, { throwOnError: true })
.then((x) => x.data!.providers)
const entries = sortProvidersByName(providers)
if (params.configId === "model") {
if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
const selection = parseModelSelection(params.value, providers)
this.sessionManager.setModel(session.id, selection.model)
this.sessionManager.setVariant(session.id, selection.variant)
} else if (params.configId === "mode") {
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
const availableModes = await this.loadAvailableModes(session.cwd)
if (!availableModes.some((mode) => mode.id === params.value)) {
throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
}
this.sessionManager.setMode(session.id, params.value)
} else {
throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
}
const updatedSession = this.sessionManager.get(session.id)
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
const availableVariants = modelVariantsFromProviders(entries, model)
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const modeState = await this.resolveModeState(session.cwd, session.id)
const modes = modeState.currentModeId
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
: undefined
return {
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
}
}
async prompt(params: PromptRequest) { async prompt(params: PromptRequest) {
const sessionID = params.sessionId const sessionID = params.sessionId
const session = this.sessionManager.get(sessionID) const session = this.sessionManager.get(sessionID)
@@ -1760,4 +1812,36 @@ export namespace ACP {
return { model: parsed, variant: undefined } return { model: parsed, variant: undefined }
} }
function buildConfigOptions(input: {
currentModelId: string
availableModels: ModelOption[]
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
}): SessionConfigOption[] {
const options: SessionConfigOption[] = [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: input.currentModelId,
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
},
]
if (input.modes) {
options.push({
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.modes.currentModeId,
options: input.modes.availableModes.map((m) => ({
value: m.id,
name: m.name,
...(m.description ? { description: m.description } : {}),
})),
})
}
return options
}
} }
+1
View File
@@ -24,6 +24,7 @@ export namespace Auth {
export class Api extends Schema.Class<Api>("ApiAuth")({ export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"), type: Schema.Literal("api"),
key: Schema.String, key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {} }) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
+5 -1
View File
@@ -125,14 +125,17 @@ import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
return { return {
externalOutputMode: "passthrough", externalOutputMode: "passthrough",
targetFps: 60, targetFps: 60,
gatherStats: false, gatherStats: false,
exitOnCtrlC: false, exitOnCtrlC: false,
useKittyKeyboard: { events: process.platform === "win32" }, useKittyKeyboard: {},
autoFocus: false, autoFocus: false,
openConsoleOnError: false, openConsoleOnError: false,
useMouse: mouseEnabled,
consoleOptions: { consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => { onCopySelection: (text) => {
@@ -758,6 +761,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
keybind: "terminal_suspend", keybind: "terminal_suspend",
category: "System", category: "System",
hidden: true, hidden: true,
enabled: tuiConfig.keybinds?.terminal_suspend !== "none",
onSelect: () => { onSelect: () => {
process.once("SIGCONT", () => { process.once("SIGCONT", () => {
renderer.resume() renderer.resume()
@@ -129,7 +129,15 @@ export function createDialogProviderOptions() {
} }
} }
if (method.type === "api") { if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />) let metadata: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({ dialog, prompts: method.prompts })
if (!value) return
metadata = value
}
return dialog.replace(() => (
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
))
} }
}, },
} }
@@ -249,6 +257,7 @@ function CodeMethod(props: CodeMethodProps) {
interface ApiMethodProps { interface ApiMethodProps {
providerID: string providerID: string
title: string title: string
metadata?: Record<string, string>
} }
function ApiMethod(props: ApiMethodProps) { function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog() const dialog = useDialog()
@@ -293,6 +302,7 @@ function ApiMethod(props: ApiMethodProps) {
auth: { auth: {
type: "api", type: "api",
key: value, key: value,
...(props.metadata ? { metadata: props.metadata } : {}),
}, },
}) })
await sdk.client.instance.dispose() await sdk.client.instance.dispose()
@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash" import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command" import { useCommandDialog } from "../dialog-command"
import { useKeyboard, useRenderer, type JSX } from "@opentui/solid" import { useRenderer, type JSX } from "@opentui/solid"
import { Editor } from "@tui/util/editor" import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit" import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard" import { Clipboard } from "../../util/clipboard"
@@ -400,20 +400,6 @@ export function Prompt(props: PromptProps) {
] ]
}) })
// Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are
// enabled, but still reports the kitty key-release event. Probe on release.
if (process.platform === "win32") {
useKeyboard(
(evt) => {
if (!input.focused) return
if (evt.name === "v" && evt.ctrl && evt.eventType === "release") {
command.trigger("prompt.paste")
}
},
{ release: true },
)
}
const ref: PromptRef = { const ref: PromptRef = {
get focused() { get focused() {
return input.focused return input.focused
@@ -148,5 +148,7 @@ const TIPS = [
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs", "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog", "Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
"Use {highlight}/rename{/highlight} to rename the current session", "Use {highlight}/rename{/highlight} to rename the current session",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell", ...(process.platform === "win32"
? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"]
: ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]),
] ]
@@ -22,6 +22,7 @@ export const TuiOptions = z.object({
.enum(["auto", "stacked"]) .enum(["auto", "stacked"])
.optional() .optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
}) })
export const TuiInfo = z export const TuiInfo = z
+9 -1
View File
@@ -111,7 +111,15 @@ export namespace TuiConfig {
} }
} }
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {}) const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
",",
)
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
const deps: Promise<void>[] = [] const deps: Promise<void>[] = []
if (acc.result.plugin?.length) { if (acc.result.plugin?.length) {
+1
View File
@@ -31,6 +31,7 @@ export namespace Flag {
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH") export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
export const OPENCODE_DISABLE_MOUSE = truthy("OPENCODE_DISABLE_MOUSE")
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE") export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT = export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT") OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
+11 -1
View File
@@ -105,7 +105,17 @@ export namespace LSPServer {
if (!tsserver) return if (!tsserver) return
const bin = await Npm.which("typescript-language-server") const bin = await Npm.which("typescript-language-server")
if (!bin) return if (!bin) return
const proc = spawn(bin, ["--stdio"], {
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
if (
!(await pathExists(path.join(root, "tsconfig.json"))) &&
!(await pathExists(path.join(root, "jsconfig.json")))
) {
args.push("--ignore-node-modules")
}
const proc = spawn(bin, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
+7 -1
View File
@@ -11,6 +11,7 @@ import { Arborist } from "@npmcli/arborist"
export namespace Npm { export namespace Npm {
const log = Log.create({ service: "npm" }) const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export const InstallFailedError = NamedError.create( export const InstallFailedError = NamedError.create(
"NpmInstallFailedError", "NpmInstallFailedError",
@@ -19,8 +20,13 @@ export namespace Npm {
}), }),
) )
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
function directory(pkg: string) { function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg) return path.join(Global.Path.cache, "packages", sanitize(pkg))
} }
function resolveEntryPoint(name: string, dir: string) { function resolveEntryPoint(name: string, dir: string) {
@@ -0,0 +1,67 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = [
...(!process.env.CLOUDFLARE_ACCOUNT_ID
? [
{
type: "text" as const,
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
]
: []),
]
return {
auth: {
provider: "cloudflare-workers-ai",
methods: [
{
type: "api",
label: "API key",
prompts,
},
],
},
}
}
export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = [
...(!process.env.CLOUDFLARE_ACCOUNT_ID
? [
{
type: "text" as const,
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
]
: []),
...(!process.env.CLOUDFLARE_GATEWAY_ID
? [
{
type: "text" as const,
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
]
: []),
]
return {
auth: {
provider: "cloudflare-ai-gateway",
methods: [
{
type: "api",
label: "Gateway API token",
prompts,
},
],
},
}
}
+9 -1
View File
@@ -10,6 +10,7 @@ import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot" import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth" import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { Effect, Layer, ServiceMap, Stream } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -46,7 +47,14 @@ export namespace Plugin {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
// Built-in plugins that are directly imported (not installed from npm) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin] const INTERNAL_PLUGINS: PluginInstance[] = [
CodexAuthPlugin,
CopilotAuthPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance { function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function" return typeof value === "function"
+22 -6
View File
@@ -1,5 +1,6 @@
import path from "path" import path from "path"
import { fileURLToPath, pathToFileURL } from "url" import { fileURLToPath, pathToFileURL } from "url"
import npa from "npm-package-arg"
import semver from "semver" import semver from "semver"
import { Npm } from "@/npm" import { Npm } from "@/npm"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
@@ -12,11 +13,24 @@ export function isDeprecatedPlugin(spec: string) {
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg)) return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
} }
function parse(spec: string) {
try {
return npa(spec)
} catch {}
}
export function parsePluginSpecifier(spec: string) { export function parsePluginSpecifier(spec: string) {
const lastAt = spec.lastIndexOf("@") const hit = parse(spec)
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec if (hit?.type === "alias" && !hit.name) {
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest" const sub = (hit as npa.AliasResult).subSpec
return { pkg, version } if (sub?.name) {
const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec
return { pkg: sub.name, version }
}
}
if (!hit?.name) return { pkg: spec, version: "" }
if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" }
return { pkg: hit.name, version: hit.rawSpec }
} }
export type PluginSource = "file" | "npm" export type PluginSource = "file" | "npm"
@@ -190,9 +204,11 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
} }
} }
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) { export async function resolvePluginTarget(spec: string) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec) if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
const result = await Npm.add(parsed.pkg + "@" + parsed.version) const hit = parse(spec)
const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec
const result = await Npm.add(pkg)
return result.directory return result.directory
} }
+38 -7
View File
@@ -672,13 +672,26 @@ export namespace Provider {
} }
}), }),
"cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) { "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID") // When baseURL is already configured (e.g. corporate config routing through a proxy/gateway),
if (!accountId) return { autoload: false } // skip the account ID check because the URL is already fully specified.
if (input.options?.baseURL) return { autoload: false }
const auth = yield* dep.auth(input.id)
const accountId =
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
if (!accountId)
return {
autoload: false,
async getModel() {
throw new Error(
"CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>",
)
},
}
const apiKey = yield* Effect.gen(function* () { const apiKey = yield* Effect.gen(function* () {
const envToken = Env.get("CLOUDFLARE_API_KEY") const envToken = Env.get("CLOUDFLARE_API_KEY")
if (envToken) return envToken if (envToken) return envToken
const auth = yield* dep.auth(input.id)
if (auth?.type === "api") return auth.key if (auth?.type === "api") return auth.key
return undefined return undefined
}) })
@@ -702,16 +715,34 @@ export namespace Provider {
} }
}), }),
"cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) { "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID") // When baseURL is already configured (e.g. corporate config), skip the ID checks.
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID") if (input.options?.baseURL) return { autoload: false }
if (!accountId || !gateway) return { autoload: false } const auth = yield* dep.auth(input.id)
const accountId =
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
const gateway =
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
if (!accountId || !gateway) {
const missing = [
!accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined,
!gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined,
].filter((x): x is string => Boolean(x))
return {
autoload: false,
async getModel() {
throw new Error(
`${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`,
)
},
}
}
// Get API token from env or auth - required for authenticated gateways // Get API token from env or auth - required for authenticated gateways
const apiToken = yield* Effect.gen(function* () { const apiToken = yield* Effect.gen(function* () {
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN") const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
if (envToken) return envToken if (envToken) return envToken
const auth = yield* dep.auth(input.id)
if (auth?.type === "api") return auth.key if (auth?.type === "api") return auth.key
return undefined return undefined
}) })
@@ -936,6 +936,12 @@ export namespace ProviderTransform {
} }
const key = sdkKey(model.api.npm) ?? model.providerID const key = sdkKey(model.api.npm) ?? model.providerID
// @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
// providerOptions["openai"], but OpenAIResponsesLanguageModel checks
// "azure" first. Pass both so model options work on either code path.
if (model.api.npm === "@ai-sdk/azure") {
return { openai: options, azure: options }
}
return { [key]: options } return { [key]: options }
} }
+48
View File
@@ -9,6 +9,7 @@ import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const wintest = process.platform === "win32" ? test : test.skip
beforeEach(async () => { beforeEach(async () => {
await Config.invalidate(true) await Config.invalidate(true)
@@ -441,6 +442,53 @@ test("merges keybind overrides across precedence layers", async () => {
}) })
}) })
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
},
})
})
wintest("keeps explicit input undo overrides on Windows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+y")
},
})
})
wintest("ignores terminal suspend bindings on Windows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } }))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
},
})
})
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
+78
View File
@@ -1,6 +1,8 @@
import { describe, expect, spyOn, test } from "bun:test" import { describe, expect, spyOn, test } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises"
import * as Lsp from "../../src/lsp/index" import * as Lsp from "../../src/lsp/index"
import * as launch from "../../src/lsp/launch"
import { LSPServer } from "../../src/lsp/server" import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -52,4 +54,80 @@ describe("lsp.spawn", () => {
await Instance.disposeAll() await Instance.disposeAll()
} }
}) })
test("spawns builtin Typescript LSP with correct arguments", async () => {
await using tmp = await tmpdir()
// Create dummy tsserver to satisfy Module.resolve
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
await fs.mkdir(tsdk, { recursive: true })
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
() =>
({
stdin: {},
stdout: {},
stderr: {},
on: () => {},
kill: () => {},
}) as any,
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSPServer.Typescript.spawn(tmp.path)
},
})
expect(spawnSpy).toHaveBeenCalled()
const args = spawnSpy.mock.calls[0][1] as string[]
expect(args).toContain("--tsserver-path")
expect(args).toContain("--tsserver-log-verbosity")
expect(args).toContain("off")
} finally {
spawnSpy.mockRestore()
}
})
test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => {
await using tmp = await tmpdir()
// Create dummy tsserver to satisfy Module.resolve
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
await fs.mkdir(tsdk, { recursive: true })
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
// NO tsconfig.json or jsconfig.json created here
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
() =>
({
stdin: {},
stdout: {},
stderr: {},
on: () => {},
kill: () => {},
}) as any,
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSPServer.Typescript.spawn(tmp.path)
},
})
expect(spawnSpy).toHaveBeenCalled()
const args = spawnSpy.mock.calls[0][1] as string[]
expect(args).toContain("--ignore-node-modules")
} finally {
spawnSpy.mockRestore()
}
})
}) })
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { Npm } from "../src/npm"
const win = process.platform === "win32"
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
expect(Npm.sanitize("prettier")).toBe("prettier")
})
test("handles git https specs", () => {
const spec = "acme@git+https://github.com/opencode/acme.git"
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
expect(Npm.sanitize(spec)).toBe(expected)
})
})
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test"
import { parsePluginSpecifier } from "../../src/plugin/shared"
describe("parsePluginSpecifier", () => {
test("parses standard npm package without version", () => {
expect(parsePluginSpecifier("acme")).toEqual({
pkg: "acme",
version: "latest",
})
})
test("parses standard npm package with version", () => {
expect(parsePluginSpecifier("acme@1.0.0")).toEqual({
pkg: "acme",
version: "1.0.0",
})
})
test("parses scoped npm package without version", () => {
expect(parsePluginSpecifier("@opencode/acme")).toEqual({
pkg: "@opencode/acme",
version: "latest",
})
})
test("parses scoped npm package with version", () => {
expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({
pkg: "@opencode/acme",
version: "1.0.0",
})
})
test("parses package with git+https url", () => {
expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({
pkg: "acme",
version: "git+https://github.com/opencode/acme.git",
})
})
test("parses scoped package with git+https url", () => {
expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({
pkg: "@opencode/acme",
version: "git+https://github.com/opencode/acme.git",
})
})
test("parses package with git+ssh url containing another @", () => {
expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "acme",
version: "git+ssh://git@github.com/opencode/acme.git",
})
})
test("parses scoped package with git+ssh url containing another @", () => {
expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "@opencode/acme",
version: "git+ssh://git@github.com/opencode/acme.git",
})
})
test("parses unaliased git+ssh url", () => {
expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "git+ssh://git@github.com/opencode/acme.git",
version: "",
})
})
test("parses npm alias using the alias name", () => {
expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({
pkg: "acme",
version: "npm:@opencode/acme@1.0.0",
})
})
test("parses bare npm protocol specifier using the target package", () => {
expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({
pkg: "@opencode/acme",
version: "1.0.0",
})
})
test("parses unversioned npm protocol specifier", () => {
expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({
pkg: "@opencode/acme",
version: "latest",
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.15", "version": "1.3.17",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.15", "version": "1.3.17",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+3
View File
@@ -1639,6 +1639,9 @@ export type OAuth = {
export type ApiAuth = { export type ApiAuth = {
type: "api" type: "api"
key: string key: string
metadata?: {
[key: string]: string
}
} }
export type WellKnownAuth = { export type WellKnownAuth = {
+9
View File
@@ -11621,6 +11621,15 @@
}, },
"key": { "key": {
"type": "string" "type": "string"
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
} }
}, },
"required": ["type", "key"] "required": ["type", "key"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.15", "version": "1.3.17",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.15", "version": "1.3.17",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+95
View File
@@ -7,6 +7,21 @@
gap: 0px; gap: 0px;
justify-content: flex-start; justify-content: flex-start;
&[data-clickable="true"] {
cursor: pointer;
}
&[data-hide-details="true"] {
[data-slot="basic-tool-tool-trigger-content"] {
flex: 1 1 auto;
max-width: 100%;
}
[data-slot="basic-tool-tool-info"] {
flex: 1 1 auto;
}
}
[data-slot="basic-tool-tool-trigger-content"] { [data-slot="basic-tool-tool-trigger-content"] {
flex: 0 1 auto; flex: 0 1 auto;
width: auto; width: auto;
@@ -165,3 +180,83 @@
flex-shrink: 0; flex-shrink: 0;
} }
} }
[data-component="task-tool-card"] {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--background-base) 92%, transparent);
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
[data-slot="basic-tool-tool-info-structured"] {
flex: 1 1 auto;
min-width: 0;
}
[data-slot="basic-tool-tool-info-main"] {
flex: 1 1 auto;
min-width: 0;
align-items: center;
}
[data-component="task-tool-spinner"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-component="task-tool-action"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--icon-weak);
margin-left: auto;
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.15s ease,
transform 0.15s ease,
color 0.15s ease;
}
[data-component="task-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
text-transform: capitalize;
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong);
}
&:hover,
&:focus-visible {
border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
[data-component="task-tool-action"] {
opacity: 1;
transform: translateX(0);
}
}
}
+86 -56
View File
@@ -34,6 +34,9 @@ export interface BasicToolProps {
locked?: boolean locked?: boolean
animated?: boolean animated?: boolean
onSubtitleClick?: () => void onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
triggerHref?: string
clickable?: boolean
} }
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
@@ -121,74 +124,101 @@ export function BasicTool(props: BasicToolProps) {
setState("open", value) setState("open", value)
} }
return ( const trigger = () => (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible"> <div
<Collapsible.Trigger> data-component="tool-trigger"
<div data-component="tool-trigger"> data-clickable={props.clickable ? "true" : undefined}
<div data-slot="basic-tool-tool-trigger-content"> data-hide-details={props.hideDetails ? "true" : undefined}
<div data-slot="basic-tool-tool-info"> >
<Switch> <div data-slot="basic-tool-tool-trigger-content">
<Match when={isTriggerTitle(props.trigger) && props.trigger}> <div data-slot="basic-tool-tool-info">
{(trigger) => ( <Switch>
<div data-slot="basic-tool-tool-info-structured"> <Match when={isTriggerTitle(props.trigger) && props.trigger}>
<div data-slot="basic-tool-tool-info-main"> {(title) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[title().titleClass ?? ""]: !!title().titleClass,
}}
>
<TextShimmer text={title().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={title().subtitle}>
<span <span
data-slot="basic-tool-tool-title" data-slot="basic-tool-tool-subtitle"
classList={{ classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass, [title().subtitleClass ?? ""]: !!title().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}} }}
> >
<TextShimmer text={trigger().title} active={pending()} /> {title().subtitle}
</span> </span>
<Show when={!pending()}> </Show>
<Show when={trigger().subtitle}> <Show when={title().args?.length}>
<For each={title().args}>
{(arg) => (
<span <span
data-slot="basic-tool-tool-subtitle" data-slot="basic-tool-tool-arg"
classList={{ classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass, [title().argsClass ?? ""]: !!title().argsClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}} }}
> >
{trigger().subtitle} {arg}
</span> </span>
</Show> )}
<Show when={trigger().args?.length}> </For>
<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={!pending() && trigger().action}>
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
</Show> </Show>
</div> </Show>
)} </div>
</Match> <Show when={!pending() && title().action}>
<Match when={true}>{props.trigger as JSX.Element}</Match> <span data-slot="basic-tool-tool-action">{title().action}</span>
</Switch> </Show>
</div> </div>
</div> )}
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}> </Match>
<Collapsible.Arrow /> <Match when={true}>{props.trigger as JSX.Element}</Match>
</Show> </Switch>
</div> </div>
</Collapsible.Trigger> </div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
)
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Show
when={props.triggerHref}
fallback={
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
}
>
{(href) => (
<Collapsible.Trigger
as="a"
href={href()}
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
)}
</Show>
<Show when={props.animated && props.children && !props.hideDetails}> <Show when={props.animated && props.children && !props.hideDetails}>
<div <div
ref={contentRef} ref={contentRef}
@@ -62,6 +62,11 @@
cursor: not-allowed; cursor: not-allowed;
} }
&[data-hide-details="true"] {
height: auto;
align-items: stretch;
}
[data-slot="collapsible-arrow"] { [data-slot="collapsible-arrow"] {
flex-shrink: 0; flex-shrink: 0;
width: 24px; width: 24px;
+120 -30
View File
@@ -22,6 +22,7 @@ import {
Message as MessageType, Message as MessageType,
Part as PartType, Part as PartType,
ReasoningPart, ReasoningPart,
Session,
TextPart, TextPart,
ToolPart, ToolPart,
UserMessage, UserMessage,
@@ -49,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip" import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button" import { IconButton } from "./icon-button"
import { Spinner } from "./spinner"
import { TextShimmer } from "./text-shimmer" import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary" import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title" import { ToolStatusTitle } from "./tool-status-title"
@@ -274,6 +276,47 @@ function agentTitle(i18n: UiI18n, type?: string) {
return i18n.t("ui.tool.agent", { type }) return i18n.t("ui.tool.agent", { type })
} }
const agentTones: Record<string, string> = {
ask: "var(--icon-agent-ask-base)",
build: "var(--icon-agent-build-base)",
docs: "var(--icon-agent-docs-base)",
plan: "var(--icon-agent-plan-base)",
}
const agentPalette = [
"var(--icon-agent-ask-base)",
"var(--icon-agent-build-base)",
"var(--icon-agent-docs-base)",
"var(--icon-agent-plan-base)",
"var(--syntax-info)",
"var(--syntax-success)",
"var(--syntax-warning)",
"var(--syntax-property)",
"var(--syntax-constant)",
"var(--text-diff-add-base)",
"var(--text-diff-delete-base)",
"var(--icon-warning-base)",
]
function tone(name: string) {
let hash = 0
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
return agentPalette[hash % agentPalette.length]
}
function taskAgent(
raw: unknown,
list?: readonly { name: string; color?: string }[],
): { name?: string; color?: string } {
if (typeof raw !== "string" || !raw) return {}
const key = raw.toLowerCase()
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
return {
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
color: item?.color ?? agentTones[key] ?? tone(key),
}
}
export function getToolInfo(tool: string, input: any = {}): ToolInfo { export function getToolInfo(tool: string, input: any = {}): ToolInfo {
const i18n = useI18n() const i18n = useI18n()
switch (tool) { switch (tool) {
@@ -402,6 +445,27 @@ function sessionLink(id: string | undefined, path: string, href?: (id: string) =
return `${path.slice(0, idx)}/session/${id}` return `${path.slice(0, idx)}/session/${id}`
} }
function currentSession(path: string) {
return path.match(/\/session\/([^/?#]+)/)?.[1]
}
function taskSession(
input: Record<string, any>,
path: string,
sessions: Session[] | undefined,
agents?: readonly { name: string; color?: string }[],
) {
const parentID = currentSession(path)
if (!parentID) return
const description = typeof input.description === "string" ? input.description : ""
const agent = taskAgent(input.subagent_type, agents).name
return (sessions ?? [])
.filter((session) => session.parentID === parentID && !session.time?.archived)
.filter((session) => (description ? session.title.startsWith(description) : true))
.filter((session) => (agent ? session.title.includes(`@${agent}`) : true))
.sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
}
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
const HIDDEN_TOOLS = new Set(["todowrite"]) const HIDDEN_TOOLS = new Set(["todowrite"])
@@ -1678,13 +1742,14 @@ ToolRegistry.register({
const data = useData() const data = useData()
const i18n = useI18n() const i18n = useI18n()
const location = useLocation() const location = useLocation()
const childSessionId = () => props.metadata.sessionId as string | undefined const childSessionId = createMemo(() => {
const type = createMemo(() => { const value = props.metadata.sessionId
const raw = props.input.subagent_type if (typeof value === "string" && value) return value
if (typeof raw !== "string" || !raw) return undefined return taskSession(props.input, location.pathname, data.store.session, data.store.agent)
return raw[0]!.toUpperCase() + raw.slice(1)
}) })
const title = createMemo(() => agentTitle(i18n, type())) const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
const tone = createMemo(() => agent().color)
const subtitle = createMemo(() => { const subtitle = createMemo(() => {
const value = props.input.description const value = props.input.description
if (typeof value === "string" && value) return value if (typeof value === "string" && value) return value
@@ -1693,37 +1758,62 @@ ToolRegistry.register({
const running = createMemo(() => props.status === "pending" || props.status === "running") const running = createMemo(() => props.status === "pending" || props.status === "running")
const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref)) const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref))
const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href())))
const titleContent = () => <TextShimmer text={title()} active={running()} /> const open = () => {
const id = childSessionId()
if (!id) return
if (data.navigateToSession) {
data.navigateToSession(id)
return
}
const value = href()
if (value) window.location.assign(value)
}
const navigate = (event: MouseEvent) => {
if (!data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
open()
}
const trigger = () => ( const trigger = () => (
<div data-slot="basic-tool-tool-info-structured"> <div data-component="task-tool-card">
<div data-slot="basic-tool-tool-info-main"> <div data-slot="basic-tool-tool-info-structured">
<span data-slot="basic-tool-tool-title" class="capitalize agent-title"> <div data-slot="basic-tool-tool-info-main">
{titleContent()} <Show when={running()}>
</span> <span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
<Show when={subtitle()}> <Spinner />
<Switch> </span>
<Match when={href()}> </Show>
<a <span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
data-slot="basic-tool-tool-subtitle" {title()}
class="clickable subagent-link" </span>
href={href()!} <Show when={subtitle()}>
onClick={(e) => e.stopPropagation()} <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
> </Show>
{subtitle()} </div>
</a>
</Match>
<Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Match>
</Switch>
</Show>
</div> </div>
<Show when={clickable()}>
<div data-component="task-tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</div> </div>
) )
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails /> return (
<BasicTool
icon="task"
status={props.status}
trigger={trigger()}
hideDetails
triggerHref={href()}
clickable={clickable()}
onTriggerClick={navigate}
/>
)
}, },
}) })
+4
View File
@@ -3,6 +3,10 @@ import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
type Data = { type Data = {
agent?: {
name: string
color?: string
}[]
provider?: ProviderListResponse provider?: ProviderListResponse
session: Session[] session: Session[]
session_status: { session_status: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.15", "version": "1.3.17",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.3.15", "version": "1.3.17",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1
View File
@@ -573,6 +573,7 @@ OpenCode can be configured using environment variables.
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` | | `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` | | `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources | | `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources |
| `OPENCODE_DISABLE_MOUSE` | boolean | Disable mouse capture in the TUI |
| `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes | | `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes |
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization | | `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) | | `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
+2 -3
View File
@@ -272,7 +272,8 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
"scroll_acceleration": { "scroll_acceleration": {
"enabled": true "enabled": true
}, },
"diff_style": "auto" "diff_style": "auto",
"mouse": true
} }
``` ```
@@ -280,8 +281,6 @@ Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible. Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
[Learn more about TUI configuration here](/docs/tui#configure).
--- ---
### Server ### Server
@@ -32,6 +32,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations | | [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | | [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Fast Apply editing, WarpGrep codebase search, and context compaction via Morph | | [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Fast Apply editing, WarpGrep codebase search, and context compaction via Morph |
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | | [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
@@ -42,6 +43,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing | | [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control | | [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control |
| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | | [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax |
| [opencode-conductor](https://github.com/derekbar90/opencode-conductor) | Protocol-Driven Workflow: Automation of the Context -> Spec -> Plan -> Implement lifecycle. |
| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | | [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity |
| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | | [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms |
| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | | [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
+47 -30
View File
@@ -490,37 +490,42 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, and more through a unified endpoint. With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) you don't need separate API keys for each provider. Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, and more through a unified endpoint. With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) you don't need separate API keys for each provider.
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway. 1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway. Note your **Account ID** and **Gateway ID**.
2. Set your Account ID and Gateway ID as environment variables. 2. Run the `/connect` command and search for **Cloudflare AI Gateway**.
```bash title="~/.bash_profile"
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
```
3. Run the `/connect` command and search for **Cloudflare AI Gateway**.
```txt ```txt
/connect /connect
``` ```
4. Enter your Cloudflare API token. 3. Enter your **Account ID** when prompted.
```txt ```txt
API key Enter your Cloudflare Account ID
└ enter └ enter
``` ```
Or set it as an environment variable. 4. Enter your **Gateway ID** when prompted.
```bash title="~/.bash_profile" ```txt
export CLOUDFLARE_API_TOKEN=your-api-token ┌ Enter your Cloudflare AI Gateway ID
└ enter
``` ```
5. Run the `/models` command to select a model. 5. Enter your **Cloudflare API token**.
```txt
┌ Gateway API token
└ enter
```
6. Run the `/models` command to select a model.
```txt ```txt
/models /models
@@ -542,27 +547,38 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
} }
``` ```
Alternatively, you can set environment variables instead of using `/connect`.
```bash title="~/.bash_profile"
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
export CLOUDFLARE_API_TOKEN=your-api-token
```
--- ---
### Cloudflare Workers AI ### Cloudflare Workers AI
Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models. Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your Account ID and create an API token. 1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your **Account ID** and create an API token.
2. Set your Account ID as an environment variable. 2. Run the `/connect` command and search for **Cloudflare Workers AI**.
```bash title="~/.bash_profile"
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
```
3. Run the `/connect` command and search for **Cloudflare Workers AI**.
```txt ```txt
/connect /connect
``` ```
4. Enter your Cloudflare API token. 3. Enter your **Account ID** when prompted.
```txt
┌ Enter your Cloudflare Account ID
└ enter
```
4. Enter your **Cloudflare API key**.
```txt ```txt
┌ API key ┌ API key
@@ -571,18 +587,19 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
└ enter └ enter
``` ```
Or set it as an environment variable.
```bash title="~/.bash_profile"
export CLOUDFLARE_API_KEY=your-api-token
```
5. Run the `/models` command to select a model. 5. Run the `/models` command to select a model.
```txt ```txt
/models /models
``` ```
Alternatively, you can set environment variables instead of using `/connect`.
```bash title="~/.bash_profile"
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
export CLOUDFLARE_API_KEY=your-api-token
```
--- ---
### Cortecs ### Cortecs
+3 -1
View File
@@ -368,7 +368,8 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
"scroll_acceleration": { "scroll_acceleration": {
"enabled": true "enabled": true
}, },
"diff_style": "auto" "diff_style": "auto",
"mouse": true
} }
``` ```
@@ -381,6 +382,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.3.15", "version": "1.3.17",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",