Compare commits

..

16 Commits

Author SHA1 Message Date
Kit Langton
9b4b419b1c test(tool): avoid network install in registry dependency test 2026-04-07 17:02:56 -04:00
Kit Langton
155eb81ab4 Merge branch 'dev' into kit/remove-console-managed-icon 2026-04-07 16:46:31 -04:00
Aiden Cline
bc1840b196 fix(opencode): clear webfetch timeouts on failed fetches (#21378) 2026-04-07 20:46:02 +00:00
Kyle Altendorf
095aeba0a7 test: disable GPG signing in test fixtures (#20386) 2026-04-07 15:26:01 -05:00
Ariane Emory
e945436b6f feat(tui): allow variant_list keybind for the "Switch model variant" command (#21185)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-07 14:46:04 -05:00
opencode-agent[bot]
6bfa82de65 chore: generate 2026-04-07 18:33:24 +00:00
Kit Langton
d83fe4b540 fix(opencode): improve console login transport errors (#21350) 2026-04-07 18:31:53 +00:00
Aiden Cline
81bdffc81c fix: ensure the alibaba provider errors are retried (#21355) 2026-04-07 17:46:01 +00:00
Ariane Emory
2549a38a71 fix(tui): use sentence case for theme mode command palette items (#21192)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-07 12:25:13 -05:00
James Long
5d48e7bd44 refactor(core): support multiple event streams in worker and remove workspaces from plugin api (#21348) 2026-04-07 13:22:34 -04:00
Adam
ec8b9810b4 feat(app): better subagent experience (#20708) 2026-04-07 11:06:23 -05:00
Adam
65318a80f7 chore: update web stats 2026-04-07 11:02:37 -05:00
Kit Langton
90e1e50ada fix(tui): remove home org hint 2026-04-07 10:27:52 -04:00
Kit Langton
234863844d fix(tui): keep home org hint stable 2026-04-07 10:21:14 -04:00
Kit Langton
f7c9d4f3c7 fix(tui): simplify console org display 2026-04-07 10:19:36 -04:00
opencode-agent[bot]
6a5aae9a84 chore: generate 2026-04-07 14:14:07 +00:00
91 changed files with 1520 additions and 1118 deletions

View File

@@ -9,7 +9,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:",
},
"devDependencies": {
@@ -3258,8 +3257,6 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],

View File

@@ -91,7 +91,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {

View File

@@ -320,6 +320,7 @@ export async function createTestProject(input?: { serverUrl?: string }) {
execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root,

View File

@@ -1,7 +1,6 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
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.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)
const link = page
.locator("a.subagent-link")
const header = page.locator("[data-session-title]")
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 })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(card).toBeVisible({ timeout: 30_000 })
await card.click()
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([])
})
} finally {

View File

@@ -238,6 +238,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"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.2": "What is the tech stack of this project?",

View File

@@ -1,6 +1,46 @@
@import "@opencode-ai/ui/styles/tailwind";
@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"] {
container-type: inline-size;
container-name: getting-started;

View File

@@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({
autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>,
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined,
@@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) {
onActivate: (directory) => {
globalSync.child(directory)
setState("hoverProject", directory)
setState("hoverSession", undefined)
},
})
@@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) {
aim.reset()
}
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => {
if (navLeave.current === undefined) return
@@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) {
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
@@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) {
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
}
@@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) {
navList: currentSessions,
sidebarExpanded,
sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon,
prefetchSession,
archiveSession,
@@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) {
sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering,
hoverProject: () => state.hoverProject,
nav: () => state.nav,
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
@@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) {
sessionProps: {
navList: currentSessions,
sidebarExpanded,
sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon,
prefetchSession,
archiveSession,
},
setHoverSession,
}
const SidebarPanel = (panelProps: {
@@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) {
const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? 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 projectName = createMemo(() => {
const item = project()
@@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) {
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
</div>
</>
@@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) {
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>

View File

@@ -8,6 +8,7 @@ import {
} from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client"
import {
childSessionOnPath,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
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", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")

View File

@@ -46,18 +46,17 @@ export function hasProjectPermissions<T>(
return Object.values(request ?? {}).some((list) => list?.some(include))
}
export const childMapByParent = (sessions: Session[] | undefined) => {
const map = new Map<string, string[]>()
for (const session of sessions ?? []) {
if (!session.parentID) continue
const existing = map.get(session.parentID)
if (existing) {
existing.push(session.id)
continue
}
map.set(session.parentID, [session.id])
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
if (!activeID || activeID === rootID) return
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
let id = activeID
while (id) {
const session = map.get(id)
if (!session?.parentID) return
if (session.parentID === rootID) return session
id = session.parentID
}
return map
}
export const displayName = (project: { name?: string; worktree: string }) =>

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 { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -18,7 +15,7 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
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 name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip">
@@ -73,13 +71,10 @@ export type SessionItemProps = {
slug: string
mobile?: boolean
dense?: boolean
popover?: boolean
children: Map<string, string[]>
showTooltip?: boolean
showChild?: boolean
level?: number
sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
@@ -95,116 +90,52 @@ const SessionRow = (props: {
hasPermissions: Accessor<boolean>
hasError: Accessor<boolean>
unseenCount: Accessor<number>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean>
warmHover: () => void
warmPress: () => void
warmFocus: () => void
cancelHoverPrefetch: () => void
}) => {
}): JSX.Element => {
const title = () => sessionTitle(props.session.title)
return (
<A
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}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<div
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()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
</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 => {
const params = useParams()
const navigate = useNavigate()
const layout = useLayout()
const language = useLanguage()
const notification = useNotification()
@@ -234,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
)
})
const tint = createMemo(() => {
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
const tint = createMemo(() => 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 nav = props.navList?.()
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 = (
<SessionRow
session={props.session}
@@ -301,86 +203,74 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
hasPermissions={hasPermissions}
hasError={hasError}
unseenCount={unseenCount}
setHoverSession={props.setHoverSession}
clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened}
warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")}
cancelHoverPrefetch={cancelHoverPrefetch}
/>
)
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 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"
>
<div class="flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1">
<Show
when={hoverEnabled()}
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title)}
gutter={10}
class="min-w-0 w-full"
>
{item}
</Tooltip>
}
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
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)
<>
<div
data-session-id={props.session.id}
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">
<Show
when={!tooltip()}
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title)}
gutter={10}
class="min-w-0 w-full"
>
{item}
</Tooltip>
}
>
{item}
</Show>
</div>
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>
</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>
<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
sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void
setHoverSession: (id: string | undefined) => void
}): JSX.Element => {
const layout = useLayout()
const language = useLanguage()
@@ -400,9 +289,8 @@ export const NewSessionItem = (props: {
<A
href={`/${props.slug}/session`}
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={() => {
props.setHoverSession(undefined)
if (layout.sidebar.opened()) return
props.clearHoverProjectSoon()
}}

View File

@@ -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 { base64Encode } from "@opencode-ai/util/encode"
import { Button } from "@opencode-ai/ui/button"
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
import { displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = {
currentDir: Accessor<string>
@@ -19,7 +19,6 @@ export type ProjectSidebarContext = {
sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined>
nav: Accessor<HTMLElement | undefined>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
@@ -32,8 +31,7 @@ export type ProjectSidebarContext = {
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
setHoverSession: (id: string | undefined) => void
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
}
export const ProjectDragOverlay = (props: {
@@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: {
const ProjectTile = (props: {
project: LocalProject
mobile?: boolean
nav: Accessor<HTMLElement | undefined>
sidebarHovering: Accessor<boolean>
selected: Accessor<boolean>
active: Accessor<boolean>
@@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: {
workspaces: Accessor<string[]>
label: (directory: string) => string
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
@@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: {
list={props.projectSessions()}
slug={base64Encode(props.project.worktree)}
dense
showTooltip
mobile={props.mobile}
popover={false}
children={props.projectChildren()}
/>
)}
</For>
@@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: {
<For each={props.workspaces()}>
{(directory) => {
const sessions = createMemo(() => props.workspaceSessions(directory))
const children = createMemo(() => props.workspaceChildren(directory))
return (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
@@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: {
list={sessions()}
slug={base64Encode(directory)}
dense
showTooltip
mobile={props.mobile}
popover={false}
children={children()}
/>
)}
</For>
@@ -310,20 +302,14 @@ export const SortableProject = (props: {
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow())
}
const workspaceChildren = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
return childMapByParent(data.session)
}
const tile = () => (
<ProjectTile
project={props.project}
mobile={props.mobile}
nav={props.ctx.nav}
sidebarHovering={props.ctx.sidebarHovering}
selected={selected}
active={active}
@@ -360,7 +346,6 @@ export const SortableProject = (props: {
if (state.menu) return
if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined)
}}
>
<ProjectPreviewPanel
@@ -371,9 +356,7 @@ export const SortableProject = (props: {
workspaces={workspaces}
label={label}
projectSessions={projectSessions}
projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
ctx={props.ctx}
language={language}
/>

View File

@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
import { sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: {
id: string
@@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = {
navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
@@ -152,7 +149,6 @@ const WorkspaceActions = (props: {
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
navigateToNewSession: () => void
}): JSX.Element => (
@@ -226,7 +222,6 @@ const WorkspaceActions = (props: {
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.setHoverSession(undefined)
props.clearHoverProjectSoon()
props.navigateToNewSession()
}}
@@ -239,12 +234,10 @@ const WorkspaceActions = (props: {
const WorkspaceSessionList = (props: {
slug: Accessor<string>
mobile?: boolean
popover?: boolean
ctx: WorkspaceSidebarContext
showNew: Accessor<boolean>
loading: Accessor<boolean>
sessions: Accessor<Session[]>
children: Accessor<Map<string, string[]>>
hasMore: Accessor<boolean>
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
@@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: {
mobile={props.mobile}
sidebarExpanded={props.ctx.sidebarExpanded}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
setHoverSession={props.ctx.setHoverSession}
/>
</Show>
<Show when={props.loading()}>
@@ -270,13 +262,8 @@ const WorkspaceSessionList = (props: {
navList={props.ctx.navList}
slug={props.slug()}
mobile={props.mobile}
popover={props.popover}
children={props.children()}
showChild
sidebarExpanded={props.ctx.sidebarExpanded}
sidebarHovering={props.ctx.sidebarHovering}
nav={props.ctx.nav}
hoverSession={props.ctx.hoverSession}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
@@ -307,7 +294,6 @@ export const SortableWorkspace = (props: {
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
popover?: boolean
}): JSX.Element => {
const navigate = useNavigate()
const params = useParams()
@@ -321,7 +307,6 @@ export const SortableWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => {
@@ -428,7 +413,6 @@ export const SortableWorkspace = (props: {
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
navigateToNewSession={() => navigate(`/${slug()}/session`)}
/>
@@ -440,12 +424,10 @@ export const SortableWorkspace = (props: {
<WorkspaceSessionList
slug={slug}
mobile={props.mobile}
popover={props.popover}
ctx={props.ctx}
showNew={showNew}
loading={loading}
sessions={sessions}
children={children}
hasMore={hasMore}
loadMore={loadMore}
language={language}
@@ -461,7 +443,6 @@ export const LocalWorkspace = (props: {
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
popover?: boolean
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
@@ -471,7 +452,6 @@ export const LocalWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.project.worktree))
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 count = createMemo(() => sessions()?.length ?? 0)
const loading = createMemo(() => !booted() && count() === 0)
@@ -489,12 +469,10 @@ export const LocalWorkspace = (props: {
<WorkspaceSessionList
slug={slug}
mobile={props.mobile}
popover={props.popover}
ctx={props.ctx}
showNew={() => false}
loading={loading}
sessions={sessions}
children={children}
hasMore={hasMore}
loadMore={loadMore}
language={language}

View File

@@ -429,6 +429,7 @@ export default function Page() {
}
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 sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
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 (composer.blocked()) return
if (composer.blocked() || isChildSession()) return
inputRef?.focus()
}
}
@@ -1127,7 +1128,10 @@ export default function Page() {
setFileTreeTab("all")
}
const focusInput = () => inputRef?.focus()
const focusInput = () => {
if (isChildSession()) return
inputRef?.focus()
}
useSessionCommands({
navigateMessageByOffset,
@@ -1658,7 +1662,7 @@ export default function Page() {
const queueEnabled = createMemo(() => {
const id = params.id
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) => {
@@ -1690,6 +1694,7 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
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)
if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve()
@@ -1820,6 +1825,7 @@ export default function Page() {
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
if (isChildSession()) return
if (composer.blocked()) return
if (busy(sessionID)) return
@@ -2001,7 +2007,7 @@ export default function Page() {
}}
onResponseSubmit={resumeScroll}
followup={
params.id
params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),

View File

@@ -1,9 +1,11 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
@@ -43,11 +45,17 @@ export function SessionComposerRegion(props: {
}
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const navigate = useNavigate()
const prompt = usePrompt()
const language = useLanguage()
const route = useSessionKey()
const sync = useSync()
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 = () =>
prompt
@@ -113,6 +121,12 @@ export function SessionComposerRegion(props: {
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height))
const openParent = () => {
const id = parentID()
if (!id) return
navigate(`/${route.params.dir}/session/${id}`)
}
createEffect(() => {
const el = store.body
if (!el) return
@@ -156,7 +170,7 @@ export function SessionComposerRegion(props: {
)}
</Show>
<Show when={!props.state.blocked()}>
<Show when={showComposer()}>
<Show
when={prompt.ready()}
fallback={
@@ -232,17 +246,40 @@ export function SessionComposerRegion(props: {
onEdit={props.followup!.onEdit}
/>
</Show>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
/>
<Show
when={child()}
fallback={
<Show when={!props.state.blocked()}>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
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>
</Show>
</Show>

View File

@@ -21,6 +21,7 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
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 current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
@@ -295,6 +306,32 @@ export function MessageTimeline(props: {
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
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 stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
@@ -317,8 +354,20 @@ export function MessageTimeline(props: {
open: false,
dismiss: null as "escape" | "outside" | null,
})
const [bar, setBar] = createStore({
ms: pace(640),
})
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
createResizeObserver(
() => head,
() => {
if (!head || head.clientWidth <= 0) return
setBar("ms", pace(head.clientWidth))
},
)
const viewShare = () => {
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 = () => {
if (!sessionID()) return
if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
@@ -646,27 +707,53 @@ export function MessageTimeline(props: {
<div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}>
<div
ref={(el) => {
head = el
setBar("ms", pace(el.clientWidth))
}}
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
relative: true,
"w-full": true,
"pb-4": 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,
}}
>
<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="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">
<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
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={{
@@ -684,15 +771,16 @@ export function MessageTimeline(props: {
</div>
</Show>
</div>
<Show when={titleLabel() || title.editing}>
<Show when={childTitle() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleLabel()}
{childTitle()}
</h1>
}
>
@@ -700,6 +788,7 @@ export function MessageTimeline(props: {
ref={(el) => {
titleRef = el
}}
data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
@@ -727,177 +816,179 @@ export function MessageTimeline(props: {
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
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,
<Show when={!parentID()}>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<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.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,
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<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
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<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>
}
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<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>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<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={viewShare}
disabled={unshareMutation.isPending}
onClick={shareSession}
disabled={shareMutation.isPending}
>
{language.t("session.share.action.view")}
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</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>
</Show>
</Show>
</div>
</div>
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</Show>
</div>
)}
</Show>

View File

@@ -5,9 +5,30 @@ const defaults: Record<string, string> = {
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) {
if (custom) return custom
return defaults[name] ?? defaults[name.toLowerCase()]
return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase())
}
export function messageAgentColor(

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "120K",
full: "120,000",
compact: "140K",
full: "140,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "800",
commits: "10,000",
monthlyUsers: "5M",
contributors: "850",
commits: "11,000",
monthlyUsers: "6.5M",
},
} as const

View File

@@ -1,4 +1,8 @@
# Keybindings vs. Keymappings
# 2.0
What we would change if we could
## Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like

View File

@@ -1,136 +0,0 @@
# Message Shape
Problem:
- stored messages need enough data to replay and resume a session later
- prompt hooks often just want to append a synthetic user/assistant message
- today that means faking ids, timestamps, and request metadata
## Option 1: Two Message Shapes
Keep `User` / `Assistant` for stored history, but clean them up.
```ts
type User = {
role: "user"
time: { created: number }
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type Assistant = {
role: "assistant"
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Add a separate transient `PromptMessage` for prompt surgery.
```ts
type PromptMessage = {
role: "user" | "assistant"
parts: PromptPart[]
}
```
Plugin hook example:
```ts
prompt.push({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
## Option 2: Prompt Mutators
Keep `User` / `Assistant` as the stored history model.
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
```ts
type PromptEditor = {
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
}
```
Plugin hook examples:
```ts
prompt.append({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
```ts
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
```
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
## Option 3: Separate Turn State
Move execution settings out of `User` and into a separate turn/request object.
```ts
type Turn = {
id: string
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type User = {
role: "user"
turnID: string
time: { created: number }
}
type Assistant = {
role: "assistant"
turnID: string
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Examples:
```ts
const turn = {
request: {
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
},
}
```
```ts
const msg = {
role: "user",
turnID: turn.id,
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
}
```
Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.

View File

@@ -1,9 +1,16 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url"
import {
type AccountError,
AccessToken,
@@ -12,6 +19,7 @@ import {
Info,
RefreshToken,
AccountServiceError,
AccountTransportError,
Login,
Org,
OrgID,
@@ -30,6 +38,7 @@ export {
type AccountError,
AccountRepoError,
AccountServiceError,
AccountTransportError,
AccessToken,
RefreshToken,
DeviceCode,
@@ -132,12 +141,27 @@ const isTokenFresh = (tokenExpiry: number | null, now: number) =>
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message)))
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
return cause
}
if (HttpClientError.isHttpClientError(cause)) {
switch (cause.reason._tag) {
case "TransportError": {
return AccountTransportError.fromHttpClientError(cause.reason)
}
default: {
return new AccountServiceError({ message, cause })
}
}
}
return new AccountServiceError({ message, cause })
}
export namespace Account {
export interface Interface {
@@ -346,8 +370,9 @@ export namespace Account {
})
const login = Effect.fn("Account.login")(function* (server: string) {
const normalizedServer = normalizeServerUrl(server)
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
@@ -359,8 +384,8 @@ export namespace Account {
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
url: `${normalizedServer}${parsed.verification_uri_complete}`,
server: normalizedServer,
expiry: parsed.expires_in,
interval: parsed.interval,
})

View File

@@ -4,6 +4,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { normalizeServerUrl } from "./url"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
@@ -125,11 +126,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
tx((db) => {
const url = normalizeServerUrl(input.url)
db.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
@@ -138,7 +141,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
target: AccountTable.id,
set: {
email: input.email,
url: input.url,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,

View File

@@ -1,4 +1,5 @@
import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
import { withStatics } from "@/util/schema"
@@ -60,7 +61,34 @@ export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceE
cause: Schema.optional(Schema.Defect),
}) {}
export type AccountError = AccountRepoError | AccountServiceError
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
method: error.request.method,
url: error.request.url,
description: error.description,
cause: error.cause,
})
}
override get message(): string {
return [
`Could not reach ${this.method} ${this.url}.`,
`This failed before the server returned an HTTP response.`,
this.description,
`Check your network, proxy, or VPN configuration and try again.`,
]
.filter(Boolean)
.join("\n")
}
}
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
export class Login extends Schema.Class<Login>("Login")({
code: DeviceCode,

View File

@@ -0,0 +1,8 @@
export const normalizeServerUrl = (input: string): string => {
const url = new URL(input)
url.search = ""
url.hash = ""
const pathname = url.pathname.replace(/\/+$/, "")
return pathname.length === 0 ? url.origin : `${url.origin}${pathname}`
}

View File

@@ -71,7 +71,7 @@ export const AgentCommand = cmd({
async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model)
return ToolRegistry.tools(model, agent)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

View File

@@ -289,9 +289,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
toast,
renderer,
})
onCleanup(() => {
api.dispose()
})
const [ready, setReady] = createSignal(false)
TuiPluginRuntime.init(api)
.catch((error) => {
@@ -602,6 +599,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
{
title: "Switch model variant",
value: "variant.list",
keybind: "variant_list",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slash: {
@@ -675,7 +673,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: "Toggle Theme Mode",
title: "Toggle theme mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
@@ -684,7 +682,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
title: locked() ? "Unlock theme mode" : "Lock theme mode",
value: "theme.mode.lock",
onSelect: (dialog) => {
if (locked()) unlock()

View File

@@ -8,7 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() {
const sync = useSync()
@@ -47,11 +46,7 @@ export function DialogModel(props: { providerID?: string }) {
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: consoleManagedProviderLabel(
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
@@ -89,9 +84,7 @@ export function DialogModel(props: { providerID?: string }) {
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected()
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
@@ -142,7 +135,7 @@ export function DialogModel(props: { providerID?: string }) {
const title = createMemo(() => {
const value = provider()
if (!value) return "Select model"
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
return value.name
})
function onSelect(providerID: string, modelID: string) {

View File

@@ -13,7 +13,7 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -49,11 +49,7 @@ export function createDialogProviderOptions() {
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: consoleManaged ? (
<text fg={theme.textMuted}>{CONSOLE_MANAGED_ICON}</text>
) : connected ? (
<text fg={theme.success}></text>
) : undefined,
gutter: connected ? <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return

View File

@@ -36,7 +36,6 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
export type PromptProps = {
sessionID?: string
@@ -96,15 +95,8 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName)
const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1)
const currentProviderLabel = createMemo(() => {
const current = local.model.current()
const provider = local.model.parsed().provider
if (!current) return provider
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, current.providerID, provider)
})
const hasRightContent = createMemo(() => Boolean(props.right || activeOrgName()))
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function promptModelWarning() {
toast.show({
@@ -1120,17 +1112,6 @@ export function Prompt(props: PromptProps) {
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
<Show when={activeOrgName()}>
<text
fg={theme.textMuted}
onMouseUp={() => {
if (!canSwitchOrgs()) return
command.trigger("console.org.switch")
}}
>
{`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}
</text>
</Show>
</box>
</Show>
</box>
@@ -1162,7 +1143,7 @@ export function Prompt(props: PromptProps) {
}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"

View File

@@ -4,8 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
setWorkspace?: (workspaceID?: string) => void
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -18,7 +17,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
events?: EventSource
}) => {
const abort = new AbortController()
let workspaceID: string | undefined
let sse: AbortController | undefined
function createSDK() {
@@ -28,7 +26,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
experimental_workspaceID: workspaceID,
})
}
@@ -90,9 +87,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
})().catch(() => {})
}
onMount(() => {
onMount(async () => {
if (props.events) {
const unsub = props.events.on(handleEvent)
const unsub = await props.events.subscribe(props.directory, handleEvent)
onCleanup(unsub)
} else {
startSSE()
@@ -109,19 +106,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get client() {
return sdk
},
get workspaceID() {
return workspaceID
},
directory: props.directory,
event: emitter,
fetch: props.fetch ?? fetch,
setWorkspace(next?: string) {
if (workspaceID === next) return
workspaceID = next
sdk = createSDK()
props.events?.setWorkspace?.(next)
if (!props.events) startSSE()
},
url: props.url,
}
},

View File

@@ -18,7 +18,7 @@ import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { Installation } from "@/installation"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { type OpencodeClient } from "@opencode-ai/sdk/v2"
type RouteEntry = {
key: symbol
@@ -43,11 +43,6 @@ type Input = {
renderer: TuiPluginApi["renderer"]
}
type TuiHostPluginApi = TuiPluginApi & {
map: Map<string | undefined, OpencodeClient>
dispose: () => void
}
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
const key = Symbol()
for (const item of list) {
@@ -206,29 +201,7 @@ function appApi(): TuiPluginApi["app"] {
}
}
export function createTuiApi(input: Input): TuiHostPluginApi {
const map = new Map<string | undefined, OpencodeClient>()
const scoped: TuiPluginApi["scopedClient"] = (workspaceID) => {
const hit = map.get(workspaceID)
if (hit) return hit
const next = createOpencodeClient({
baseUrl: input.sdk.url,
fetch: input.sdk.fetch,
directory: input.sync.data.path.directory || input.sdk.directory,
experimental_workspaceID: workspaceID,
})
map.set(workspaceID, next)
return next
}
const workspace: TuiPluginApi["workspace"] = {
current() {
return input.sdk.workspaceID
},
set(workspaceID) {
input.sdk.setWorkspace(workspaceID)
},
}
export function createTuiApi(input: Input): TuiPluginApi {
const lifecycle: TuiPluginApi["lifecycle"] = {
signal: new AbortController().signal,
onDispose() {
@@ -369,8 +342,6 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
get client() {
return input.sdk.client
},
scopedClient: scoped,
workspace,
event: input.sdk.event,
renderer: input.renderer,
slots: {
@@ -422,9 +393,5 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
return input.theme.ready
},
},
map,
dispose() {
map.clear()
},
}
}

View File

@@ -543,8 +543,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
get client() {
return api.client
},
scopedClient: api.scopedClient,
workspace: api.workspace,
event,
renderer: api.renderer,
slots,

View File

@@ -167,12 +167,6 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
createEffect(() => {
if (session()?.workspaceID) {
sdk.setWorkspace(session()?.workspaceID)
}
})
createEffect(async () => {
await sync.session
.sync(route.sessionID)

View File

@@ -43,9 +43,18 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) => client.on<Event>("event", handler),
setWorkspace: (workspaceID) => {
void client.call("setWorkspace", { workspaceID })
subscribe: async (directory, handler) => {
const id = await client.call("subscribe", { directory })
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
if (e.id === id) {
handler(e.event)
}
})
return () => {
unsub()
client.call("unsubscribe", { id })
}
},
}
}

View File

@@ -1,5 +1,3 @@
export const CONSOLE_MANAGED_ICON = "⌂"
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
Array.isArray(consoleManagedProviders)
? consoleManagedProviders.includes(providerID)
@@ -7,14 +5,3 @@ const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, provi
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
contains(consoleManagedProviders, providerID)
export const consoleManagedProviderSuffix = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
) => (contains(consoleManagedProviders, providerID) ? ` ${CONSOLE_MANAGED_ICON}` : "")
export const consoleManagedProviderLabel = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
providerName: string,
) => `${providerName}${consoleManagedProviderSuffix(consoleManagedProviders, providerID)}`

View File

@@ -45,20 +45,20 @@ GlobalBus.on("event", (event) => {
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
}
const eventStreams = new Map<string, AbortController>()
function startEventStream(directory: string) {
const id = crypto.randomUUID()
const startEventStream = (input: { directory: string; workspaceID?: string }) => {
if (eventStream.abort) eventStream.abort.abort()
const abort = new AbortController()
eventStream.abort = abort
const signal = abort.signal
;(async () => {
eventStreams.set(id, abort)
async function run() {
while (!signal.aborted) {
const shouldReconnect = await Instance.provide({
directory: input.directory,
directory,
init: InstanceBootstrap,
fn: () =>
new Promise<boolean>((resolve) => {
@@ -77,7 +77,10 @@ const startEventStream = (input: { directory: string; workspaceID?: string }) =>
}
const unsub = Bus.subscribeAll((event) => {
Rpc.emit("event", event as Event)
Rpc.emit("event", {
id,
event: event as Event,
})
if (event.type === Bus.InstanceDisposed.type) {
settle(true)
}
@@ -104,14 +107,24 @@ const startEventStream = (input: { directory: string; workspaceID?: string }) =>
await sleep(250)
}
}
})().catch((error) => {
}
run().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
return id
}
startEventStream({ directory: process.cwd() })
function stopEventStream(id: string) {
const abortController = eventStreams.get(id)
if (!abortController) return
abortController.abort()
eventStreams.delete(id)
}
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
@@ -154,12 +167,19 @@ export const rpc = {
async reload() {
await Config.invalidate(true)
},
async setWorkspace(input: { workspaceID?: string }) {
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
async subscribe(input: { directory: string | undefined }) {
return startEventStream(input.directory || process.cwd())
},
async unsubscribe(input: { id: string }) {
stopEventStream(input.id)
},
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
for (const id of [...eventStreams.keys()]) {
stopEventStream(id)
}
await Instance.disposeAll()
if (server) await server.stop(true)
},

View File

@@ -1,3 +1,4 @@
import { AccountServiceError, AccountTransportError } from "@/account"
import { ConfigMarkdown } from "@/config/markdown"
import { errorFormat } from "@/util/error"
import { Config } from "../config/config"
@@ -8,6 +9,9 @@ import { UI } from "./ui"
export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (input instanceof AccountTransportError || input instanceof AccountServiceError) {
return input.message
}
if (Provider.ModelNotFoundError.isInstance(input)) {
const { providerID, modelID, suggestions } = input.data
return [

View File

@@ -669,6 +669,7 @@ export namespace Config {
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
variant_list: z.string().optional().default("none").describe("List model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("return").describe("Submit input"),

View File

@@ -11,6 +11,7 @@ import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
@@ -23,6 +24,7 @@ import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@@ -35,6 +37,7 @@ import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
@@ -47,7 +50,6 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -431,10 +433,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
})
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
providerID: input.model.providerID,
})) {
for (const item of yield* registry.tools(
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
input.agent,
)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@@ -558,7 +560,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const taskTool = yield* registry.fromID(TaskTool.id)
const taskTool = yield* Effect.promise(() => registry.named.task.init())
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -581,7 +583,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
tool: TaskTool.id,
tool: registry.named.task.id,
state: {
status: "running",
input: {
@@ -1111,7 +1113,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
]
const read = yield* registry.fromID("read").pipe(
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) =>
@@ -1175,7 +1177,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const result = yield* registry.fromID("read").pipe(
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
Effect.flatMap((t) =>
Effect.promise(() =>
t.execute(args, {

View File

@@ -58,6 +58,19 @@ export namespace SessionRetry {
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}
// Check for rate limit patterns in plain text error messages
const msg = error.data?.message
if (typeof msg === "string") {
const lower = msg.toLowerCase()
if (
lower.includes("rate increased too quickly") ||
lower.includes("rate limit") ||
lower.includes("too many requests")
) {
return msg
}
}
const json = iife(() => {
try {
if (typeof error.data?.message === "string") {

View File

@@ -239,28 +239,22 @@ export namespace Skill {
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return [
"## Available Skills",
...list
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -50,22 +50,6 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
})
type Part = {
type: string
text: string
@@ -468,7 +452,21 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {

View File

@@ -7,22 +7,20 @@ import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"])
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
const Parameters = z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
})
export const BatchTool = Tool.define("batch", async () => {
return {
description: DESCRIPTION,
parameters: Parameters,
parameters: z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
}),
formatValidationError(error) {
const formattedErrors = error.issues
.map((issue) => {

View File

@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
},
}
},
}
} satisfies Tool.Def<typeof parameters, Metadata>
}),
)

View File

@@ -12,8 +12,10 @@ import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Config } from "../config/config"
import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
@@ -26,7 +28,6 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
@@ -38,21 +39,24 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
type State = {
custom: Tool.Def[]
builtin: Tool.Def[]
custom: Tool.Info[]
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly tools: (model: { providerID: ProviderID; modelID: ModelID }) => Effect.Effect<Tool.Def[]>
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
readonly named: {
task: Tool.Info
read: Tool.Info
}
readonly tools: (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) => Effect.Effect<(Tool.Def & { id: string })[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
@@ -75,34 +79,33 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Def[] = []
const custom: Tool.Info[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
},
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
}
}
@@ -128,96 +131,104 @@ export namespace ToolRegistry {
}
}
const cfg = yield* config.get()
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return {
custom,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
}
return { custom }
}),
)
const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
const invalid = yield* build(InvalidTool)
const ask = yield* build(QuestionTool)
const bash = yield* build(BashTool)
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
const s = yield* InstanceState.get(state)
return [...s.builtin, ...s.custom] as Tool.Def[]
const tools = yield* all(s.custom)
return tools.map((t) => t.id)
})
const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
const tools = yield* all()
const match = tools.find((tool) => tool.id === id)
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
return match
})
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
return (yield* all()).map((tool) => tool.id)
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (model: {
providerID: ProviderID
modelID: ModelID
}) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
const s = yield* InstanceState.get(state)
const allTools = yield* all(s.custom)
const filtered = allTools.filter((tool) => {
if (tool.id === "codesearch" || tool.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Def) {
Effect.fnUntraced(function* (tool: Tool.Info) {
using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
description: tool.description,
parameters: tool.parameters,
description: next.description,
parameters: next.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
parameters: output.parameters,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
execute: next.execute,
formatValidationError: next.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
return Service.of({ ids, tools, all, fromID })
return Service.of({ ids, named: { task, read }, tools })
}),
)
@@ -242,10 +253,13 @@ export namespace ToolRegistry {
return runPromise((svc) => svc.ids())
}
export async function tools(model: {
providerID: ProviderID
modelID: ModelID
}): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model))
export async function tools(
model: {
providerID: ProviderID
modelID: ModelID
},
agent?: Agent.Info,
): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model, agent))
}
}

View File

@@ -6,12 +6,9 @@ import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async (ctx) => {
const list = await Skill.available(ctx?.agent)
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.all()
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
@@ -36,10 +33,14 @@ export const SkillTool = Tool.define("skill", async () => {
.join(", ")
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})
return {
description,
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {

View File

@@ -4,11 +4,13 @@ import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
@@ -23,12 +25,19 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define("task", async () => {
export const TaskTool = Tool.define("task", async (ctx) => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
agents
.toSorted((a, b) => a.name.localeCompare(b.name))
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)

View File

@@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
},
}
},
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
} satisfies Tool.Def<typeof parameters, Metadata>
}),
)

View File

@@ -1,16 +1,20 @@
import z from "zod"
import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
import { Agent } from "@/agent/agent"
export namespace Tool {
interface Metadata {
[key: string]: any
}
export interface InitContext {
agent?: Agent.Info
}
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
messageID: MessageID
@@ -22,9 +26,7 @@ export namespace Tool {
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(
@@ -38,14 +40,10 @@ export namespace Tool {
}>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: () => Promise<DefWithoutID<Parameters, M>>
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
}
export type InferParameters<T> =
@@ -59,10 +57,10 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
) {
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
return async (initCtx?: InitContext) => {
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
@@ -80,7 +78,7 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
return {
...result,
output: truncated.content,
@@ -97,7 +95,7 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
): Info<Parameters, Result> {
return {
id,
@@ -107,18 +105,8 @@ export namespace Tool {
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
}
export function init(info: Info): Effect.Effect<Def, never, any> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {
...init,
id: info.id,
}
})
}
}

View File

@@ -3,6 +3,7 @@ import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
import { iife } from "@/util/iife"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -62,15 +63,18 @@ export const WebFetchTool = Tool.define("webfetch", {
"Accept-Language": "en-US,en;q=0.9",
}
const initial = await fetch(params.url, { signal, headers })
const response = await iife(async () => {
try {
const initial = await fetch(params.url, { signal, headers })
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
const response =
initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial
clearTimeout()
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial
} finally {
clearTimeout()
}
})
if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`)

View File

@@ -11,25 +11,6 @@ const API_CONFIG = {
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
interface McpSearchRequest {
jsonrpc: string
id: number
@@ -61,7 +42,26 @@ export const WebSearchTool = Tool.define("websearch", async () => {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: Parameters,
parameters: z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe(
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",

View File

@@ -56,6 +56,32 @@ it.live("persistAccount inserts and getRow retrieves", () =>
}),
)
it.live("persistAccount normalizes trailing slashes in stored server URLs", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com/",
accessToken: AccessToken.make("at_123"),
refreshToken: RefreshToken.make("rt_456"),
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const active = yield* AccountRepo.use((r) => r.active())
const list = yield* AccountRepo.use((r) => r.list())
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
expect(list[0]?.url).toBe("https://control.example.com")
}),
)
it.live("persistAccount sets the active account and org", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")

View File

@@ -1,10 +1,20 @@
import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { Account } from "../../src/account"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import {
AccessToken,
AccountID,
AccountTransportError,
DeviceCode,
Login,
Org,
OrgID,
RefreshToken,
UserCode,
} from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect"
@@ -57,6 +67,59 @@ const deviceTokenClient = (body: unknown, status = 400) =>
const poll = (body: unknown, status = 400) =>
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.live("login normalizes trailing slashes in the provided server URL", () =>
Effect.gen(function* () {
const seen: Array<string> = []
const client = HttpClient.make((req) =>
Effect.gen(function* () {
seen.push(`${req.method} ${req.url}`)
if (req.url === "https://one.example.com/auth/device/code") {
return json(req, {
device_code: "device-code",
user_code: "user-code",
verification_uri_complete: "/device?user_code=user-code",
expires_in: 600,
interval: 5,
})
}
return json(req, {}, 404)
}),
)
const result = yield* Account.Service.use((s) => s.login("https://one.example.com/")).pipe(
Effect.provide(live(client)),
)
expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
expect(result.server).toBe("https://one.example.com")
expect(result.url).toBe("https://one.example.com/device?user_code=user-code")
}),
)
it.live("login maps transport failures to account transport errors", () =>
Effect.gen(function* () {
const client = HttpClient.make((req) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request: req }),
}),
),
)
const error = yield* Effect.flip(
Account.Service.use((s) => s.login("https://one.example.com")).pipe(Effect.provide(live(client))),
)
expect(error).toBeInstanceOf(AccountTransportError)
if (error instanceof AccountTransportError) {
expect(error.method).toBe("POST")
expect(error.url).toBe("https://one.example.com/auth/device/code")
}
}),
)
it.live("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>

View File

@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { AccountTransportError } from "../../src/account/schema"
import { FormatError } from "../../src/cli/error"
describe("cli.error", () => {
test("formats account transport errors clearly", () => {
const error = new AccountTransportError({
method: "POST",
url: "https://console.opencode.ai/auth/device/code",
})
const formatted = FormatError(error)
expect(formatted).toContain("Could not reach POST https://console.opencode.ai/auth/device/code.")
expect(formatted).toContain("This failed before the server returned an HTTP response.")
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
})
})

View File

@@ -403,7 +403,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
await Instance.provide({
@@ -441,7 +441,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "gone.txt")
await fs.writeFile(filepath, "content\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await fs.rm(filepath)
await Instance.provide({
@@ -460,7 +460,7 @@ describe("file/index Filesystem patterns", () => {
await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "initial"`.cwd(tmp.path).quiet()
await $`git commit -m "initial"`.cwd(tmp.path).quiet()
// Modify one, delete one, add one
await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8")
@@ -510,7 +510,7 @@ describe("file/index Filesystem patterns", () => {
for (let i = 0; i < 256; i++) binaryData[i] = i
await fs.writeFile(filepath, binaryData)
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
await $`git commit -m "add binary"`.cwd(tmp.path).quiet()
// Modify the binary
const modified = Buffer.alloc(512)
for (let i = 0; i < 512; i++) modified[i] = i % 256
@@ -826,7 +826,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original content\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "modified content\n", "utf-8")
await Instance.provide({
@@ -849,7 +849,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "staged.txt")
await fs.writeFile(filepath, "before\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "after\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
@@ -868,7 +868,7 @@ describe("file/index Filesystem patterns", () => {
const filepath = path.join(tmp.path, "clean.txt")
await fs.writeFile(filepath, "unchanged\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await $`git commit -m "add file"`.cwd(tmp.path).quiet()
await Instance.provide({
directory: tmp.path,

View File

@@ -49,6 +49,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
if (options?.git) {
await $`git init`.cwd(dirpath).quiet()
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
await $`git config commit.gpgsign false`.cwd(dirpath).quiet()
await $`git config user.email "test@opencode.test"`.cwd(dirpath).quiet()
await $`git config user.name "Test"`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
@@ -100,6 +101,7 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.
if (options?.git) {
yield* git("init")
yield* git("config", "core.fsmonitor", "false")
yield* git("config", "commit.gpgsign", "false")
yield* git("config", "user.email", "test@opencode.test")
yield* git("config", "user.name", "Test")
yield* git("commit", "--allow-empty", "-m", "root commit")

View File

@@ -82,8 +82,6 @@ function themeCurrent(): HostPluginApi["theme"]["current"] {
type Opts = {
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
scopedClient?: HostPluginApi["scopedClient"]
workspace?: Partial<HostPluginApi["workspace"]>
renderer?: HostPluginApi["renderer"]
count?: Count
keybind?: Partial<HostPluginApi["keybind"]>
@@ -127,11 +125,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
? () => opts.client as HostPluginApi["client"]
: fallback
const client = () => read()
const scopedClient = opts.scopedClient ?? ((_workspaceID?: string) => client())
const workspace: HostPluginApi["workspace"] = {
current: opts.workspace?.current ?? (() => undefined),
set: opts.workspace?.set ?? (() => {}),
}
let depth = 0
let size: "medium" | "large" | "xlarge" = "medium"
const has = opts.theme?.has ?? (() => false)
@@ -171,8 +164,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
get client() {
return client()
},
scopedClient,
workspace,
event: {
on: () => {
if (count) count.event_add += 1

View File

@@ -57,6 +57,7 @@ describe("migrateFromGlobal", () => {
await $`git init`.cwd(tmp.path).quiet()
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
const { project: pre } = await Project.fromDirectory(tmp.path)
expect(pre.id).toBe(ProjectID.global)

View File

@@ -145,6 +145,25 @@ describe("session.retry.retryable", () => {
expect(SessionRetry.retryable(error)).toBeUndefined()
})
test("retries plain text rate limit errors from Alibaba", () => {
const msg =
"Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toBe(msg)
})
test("retries plain text rate limit errors", () => {
const msg = "Rate limit exceeded, please try again later"
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toBe(msg)
})
test("retries too many requests in plain text", () => {
const msg = "Too many requests, please slow down"
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toBe(msg)
})
test("does not retry context overflow errors", () => {
const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model",

View File

@@ -26,7 +26,7 @@ async function bootstrap() {
await Filesystem.write(`${dir}/a.txt`, aContent)
await Filesystem.write(`${dir}/b.txt`, bContent)
await $`git add .`.cwd(dir).quiet()
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
await $`git commit -m init`.cwd(dir).quiet()
return {
aContent,
bContent,

View File

@@ -98,6 +98,37 @@ describe("tool.registry", () => {
}),
)
await Bun.write(
path.join(opencodeDir, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
},
}),
)
const cowsayDir = path.join(opencodeDir, "node_modules", "cowsay")
await fs.mkdir(cowsayDir, { recursive: true })
await Bun.write(
path.join(cowsayDir, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
)
await Bun.write(
path.join(cowsayDir, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
)
await Bun.write(
path.join(toolsDir, "cowsay.ts"),
[

View File

@@ -28,8 +28,9 @@ describe("tool.task", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const first = await TaskTool.init()
const second = await TaskTool.init()
const build = await Agent.get("build")
const first = await TaskTool.init({ agent: build })
const second = await TaskTool.init({ agent: build })
expect(first.description).toBe(second.description)

View File

@@ -3,6 +3,7 @@ import z from "zod"
import { Tool } from "../../src/tool/tool"
const params = z.object({ input: z.string() })
const defaultArgs = { input: "test" }
function makeTool(id: string, executeFn?: () => void) {
return {
@@ -29,6 +30,36 @@ describe("Tool.define", () => {
expect(original.execute).toBe(originalExecute)
})
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
let calls = 0
const tool = Tool.define(
"test-tool",
makeTool("test", () => calls++),
)
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
calls = 0
let stack = ""
const exec = resolved.execute
resolved.execute = async (args: any, ctx: any) => {
const result = await exec.call(resolved, args, ctx)
stack = new Error().stack || ""
return result
}
await resolved.execute(defaultArgs, {} as any)
expect(calls).toBe(1)
const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
expect(frames).toBeLessThan(5)
})
test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
@@ -46,4 +77,25 @@ describe("Tool.define", () => {
expect(first).not.toBe(second)
})
test("validation still works after many init() calls", async () => {
const tool = Tool.define("test-validation", {
description: "validation test",
parameters: z.object({ count: z.number().int().positive() }),
async execute(args) {
return { title: "test", output: String(args.count), metadata: {} }
},
})
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
const result = await resolved.execute({ count: 42 }, {} as any)
expect(result.output).toBe("42")
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
})
})

View File

@@ -17,6 +17,8 @@ const ctx = {
ask: async () => {},
}
type TimerID = ReturnType<typeof setTimeout>
async function withFetch(
mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
fn: () => Promise<void>,
@@ -30,6 +32,32 @@ async function withFetch(
}
}
async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise<void>) {
const set = globalThis.setTimeout
const clear = globalThis.clearTimeout
const ids: TimerID[] = []
const cleared: TimerID[] = []
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
const id = set(...args)
ids.push(id)
return id
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: TimerID) => {
if (id !== undefined) cleared.push(id)
return clear(id)
}) as typeof clearTimeout
try {
await fn({ ids, cleared })
} finally {
ids.forEach(clear)
globalThis.setTimeout = set
globalThis.clearTimeout = clear
}
}
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
@@ -98,4 +126,29 @@ describe("tool.webfetch", () => {
},
)
})
test("clears timeout when fetch rejects", async () => {
await withTimers(async ({ ids, cleared }) => {
await withFetch(
async () => {
throw new Error("boom")
},
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
await expect(
webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx),
).rejects.toThrow("boom")
},
})
},
)
expect(ids).toHaveLength(1)
expect(cleared).toHaveLength(1)
expect(cleared[0]).toBe(ids[0])
})
})
})

View File

@@ -484,8 +484,6 @@ export type TuiPluginApi = {
state: TuiState
theme: TuiTheme
client: OpencodeClient
scopedClient: (workspaceID?: string) => OpencodeClient
workspace: TuiWorkspace
event: TuiEventBus
renderer: CliRenderer
slots: TuiSlots

View File

@@ -77,6 +77,5 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID,
}),
)
const result = new OpencodeClient({ client })
return result
return new OpencodeClient({ client })
}

View File

@@ -1,32 +0,0 @@
import type { Part, UserMessage } from "./client.js"
export const message = {
user(input: Omit<UserMessage, "role" | "time" | "id"> & { parts: Omit<Part, "id" | "sessionID" | "messageID">[] }): {
info: UserMessage
parts: Part[]
} {
const { parts, ...rest } = input
const info: UserMessage = {
...rest,
id: "asdasd",
time: {
created: Date.now(),
},
role: "user",
}
return {
info,
parts: input.parts.map(
(part) =>
({
...part,
id: "asdasd",
messageID: info.id,
sessionID: info.sessionID,
}) as Part,
),
}
},
}

View File

@@ -5,9 +5,6 @@ import { createOpencodeClient } from "./client.js"
import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js"
export * as data from "./data.js"
import * as data from "./data.js"
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
...options,

View File

@@ -8717,6 +8717,9 @@
},
"modelID": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"required": ["providerID", "modelID"]
@@ -8732,9 +8735,6 @@
"additionalProperties": {
"type": "boolean"
}
},
"variant": {
"type": "string"
}
},
"required": ["id", "sessionID", "role", "time", "agent", "model"]

View File

@@ -7,6 +7,21 @@
gap: 0px;
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"] {
flex: 0 1 auto;
width: auto;
@@ -165,3 +180,80 @@
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;
transition:
opacity 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;
}
}
}

View File

@@ -34,6 +34,9 @@ export interface BasicToolProps {
locked?: boolean
animated?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
triggerHref?: string
clickable?: boolean
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
@@ -121,74 +124,101 @@ export function BasicTool(props: BasicToolProps) {
setState("open", value)
}
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
const trigger = () => (
<div
data-component="tool-trigger"
data-clickable={props.clickable ? "true" : undefined}
data-hide-details={props.hideDetails ? "true" : undefined}
>
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(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
data-slot="basic-tool-tool-title"
data-slot="basic-tool-tool-subtitle"
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>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
</Show>
<Show when={title().args?.length}>
<For each={title().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-subtitle"
data-slot="basic-tool-tool-arg"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
[title().argsClass ?? ""]: !!title().argsClass,
}}
>
{trigger().subtitle}
{arg}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && trigger().action}>
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
)}
</For>
</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</Show>
</div>
<Show when={!pending() && title().action}>
<span data-slot="basic-tool-tool-action">{title().action}</span>
</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</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}>
<div
ref={contentRef}

View File

@@ -62,6 +62,11 @@
cursor: not-allowed;
}
&[data-hide-details="true"] {
height: auto;
align-items: stretch;
}
[data-slot="collapsible-arrow"] {
flex-shrink: 0;
width: 24px;

View File

@@ -22,6 +22,7 @@ import {
Message as MessageType,
Part as PartType,
ReasoningPart,
Session,
TextPart,
ToolPart,
UserMessage,
@@ -49,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { Spinner } from "./spinner"
import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
@@ -274,6 +276,47 @@ function agentTitle(i18n: UiI18n, type?: string) {
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 {
const i18n = useI18n()
switch (tool) {
@@ -402,6 +445,27 @@ function sessionLink(id: string | undefined, path: string, href?: (id: string) =
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 HIDDEN_TOOLS = new Set(["todowrite"])
@@ -1678,13 +1742,14 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const location = useLocation()
const childSessionId = () => props.metadata.sessionId as string | undefined
const type = createMemo(() => {
const raw = props.input.subagent_type
if (typeof raw !== "string" || !raw) return undefined
return raw[0]!.toUpperCase() + raw.slice(1)
const childSessionId = createMemo(() => {
const value = props.metadata.sessionId
if (typeof value === "string" && value) return value
return taskSession(props.input, location.pathname, data.store.session, data.store.agent)
})
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 value = props.input.description
if (typeof value === "string" && value) return value
@@ -1693,37 +1758,62 @@ ToolRegistry.register({
const running = createMemo(() => props.status === "pending" || props.status === "running")
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 = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title" class="capitalize agent-title">
{titleContent()}
</span>
<Show when={subtitle()}>
<Switch>
<Match when={href()}>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={href()!}
onClick={(e) => e.stopPropagation()}
>
{subtitle()}
</a>
</Match>
<Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Match>
</Switch>
</Show>
<div data-component="task-tool-card">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<Show when={running()}>
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
<Spinner />
</span>
</Show>
<span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
{title()}
</span>
<Show when={subtitle()}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show>
</div>
</div>
<Show when={clickable()}>
<div data-component="task-tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</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}
/>
)
},
})

View File

@@ -3,6 +3,10 @@ import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
type Data = {
agent?: {
name: string
color?: string
}[]
provider?: ProviderListResponse
session: Session[]
session_status: {

View File

@@ -53,6 +53,7 @@ description: خصّص اختصارات لوحة المفاتيح.
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode ima listu veza tipki koje možete prilagoditi putem `tui.json`.
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode har en liste over nøglebindinger, som du kan tilpasse gennem `tui.json
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über `tui.j
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode tiene una lista de combinaciones de teclas que puede personalizar a tra
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode a une liste de raccourcis clavier que vous pouvez personnaliser via la
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode ha una lista di scorciatoie che puoi personalizzare tramite `tui.json`.
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode には、`tui.json` を通じてカスタマイズできるキーバイ
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`.
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode에는 `tui.json`을 통해 커스터마이즈할 수 있는 키바인
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode har en liste over tastebindinger som du kan tilpasse gjennom `tui.json`
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode zawiera listę skrótów klawiszowych, które można dostosować za pom
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ O opencode tem uma lista de atalhos de teclado que você pode personalizar atrav
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ opencode имеет список сочетаний клавиш, которые
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode มีรายการปุ่มลัดที่คุณปร
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ opencode, `tui.json` aracılığıyla özelleştirebileceğiniz bir tuş bağlan
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode 提供了一系列快捷键,您可以通过 `tui.json` 进行自定
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",

View File

@@ -53,6 +53,7 @@ OpenCode 提供了一系列快捷鍵,您可以透過 `tui.json` 進行自訂
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",