mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-08 23:14:54 +00:00
Compare commits
3 Commits
opencode/s
...
opencode/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
221aa8c19e | ||
|
|
544d012515 | ||
|
|
ba41b6928f |
34
bun.lock
34
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -141,7 +141,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -165,7 +165,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -189,7 +189,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -222,7 +222,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -299,7 +299,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -371,7 +371,6 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.1",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
@@ -413,7 +412,6 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
@@ -430,7 +428,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -464,7 +462,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -479,7 +477,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -514,7 +512,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -562,7 +560,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -573,7 +571,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LRhPPrOKCGUSCEWTpAxPdWKTKVNkg82WrvD25cP3jts=",
|
||||
"aarch64-linux": "sha256-sbNxkil47n+B7v6ds5EYFybLytXUyRlu0Cpka0ZmDx4=",
|
||||
"aarch64-darwin": "sha256-5+99gtpIHGygMW3VBAexNhmaORgI8LCxPk/Gf1fW/ds=",
|
||||
"x86_64-darwin": "sha256-LqnvZGGnQaRxIoowOr5gf6lFgDhbgQhVPiAcRTtU6fE="
|
||||
"x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=",
|
||||
"aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=",
|
||||
"aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=",
|
||||
"x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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)
|
||||
@@ -29,33 +30,15 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
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"]')
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
.filter({ hasText: /open child session/i })
|
||||
.first()
|
||||
await expect(card).toBeVisible({ timeout: 30_000 })
|
||||
await card.click()
|
||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { 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(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -238,8 +238,6 @@ 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?",
|
||||
|
||||
@@ -1,46 +1,6 @@
|
||||
@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;
|
||||
|
||||
@@ -150,6 +150,7 @@ 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,
|
||||
@@ -193,6 +194,7 @@ export default function Layout(props: ParentProps) {
|
||||
onActivate: (directory) => {
|
||||
globalSync.child(directory)
|
||||
setState("hoverProject", directory)
|
||||
setState("hoverSession", undefined)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -229,6 +231,7 @@ 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
|
||||
@@ -238,6 +241,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const reset = () => {
|
||||
disarm()
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
}
|
||||
|
||||
@@ -248,6 +252,7 @@ export default function Layout(props: ParentProps) {
|
||||
navLeave.current = window.setTimeout(() => {
|
||||
navLeave.current = undefined
|
||||
setHoverProject(undefined)
|
||||
setState("hoverSession", undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
@@ -1967,6 +1972,9 @@ export default function Layout(props: ParentProps) {
|
||||
navList: currentSessions,
|
||||
sidebarExpanded,
|
||||
sidebarHovering,
|
||||
nav: () => state.nav,
|
||||
hoverSession: () => state.hoverSession,
|
||||
setHoverSession,
|
||||
clearHoverProjectSoon,
|
||||
prefetchSession,
|
||||
archiveSession,
|
||||
@@ -1995,6 +2003,7 @@ 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),
|
||||
@@ -2013,10 +2022,15 @@ 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: {
|
||||
@@ -2027,6 +2041,7 @@ 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()
|
||||
@@ -2228,6 +2243,7 @@ export default function Layout(props: ParentProps) {
|
||||
project={project()!}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
popover={popover()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -2272,6 +2288,7 @@ export default function Layout(props: ParentProps) {
|
||||
project={project()!}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
popover={popover()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "./deep-links"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
childSessionOnPath,
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
@@ -199,19 +198,6 @@ 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")
|
||||
|
||||
@@ -46,17 +46,18 @@ export function hasProjectPermissions<T>(
|
||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||
}
|
||||
|
||||
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
|
||||
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])
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Session, TextPart, UserMessage } 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, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
@@ -15,7 +18,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 { childSessionOnPath, hasProjectPermissions } from "./helpers"
|
||||
import { hasProjectPermissions } from "./helpers"
|
||||
|
||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
@@ -36,7 +39,6 @@ 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">
|
||||
@@ -71,10 +73,13 @@ export type SessionItemProps = {
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
showTooltip?: boolean
|
||||
showChild?: boolean
|
||||
level?: number
|
||||
popover?: boolean
|
||||
children: Map<string, string[]>
|
||||
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>
|
||||
@@ -90,52 +95,116 @@ 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
|
||||
}): JSX.Element => {
|
||||
cancelHoverPrefetch: () => void
|
||||
}) => {
|
||||
const title = () => sessionTitle(props.session.title)
|
||||
|
||||
return (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center gap-1 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()
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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()
|
||||
@@ -165,13 +234,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
)
|
||||
})
|
||||
|
||||
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 tint = createMemo(() => {
|
||||
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -192,6 +266,30 @@ 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}
|
||||
@@ -203,74 +301,86 @@ 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 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>
|
||||
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<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)
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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)
|
||||
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</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>
|
||||
<Show when={currentChild()}>
|
||||
{(child) => (
|
||||
<div class="w-full">
|
||||
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,6 +390,7 @@ export const NewSessionItem = (props: {
|
||||
dense?: boolean
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
clearHoverProjectSoon: () => void
|
||||
setHoverSession: (id: string | undefined) => void
|
||||
}): JSX.Element => {
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
@@ -289,8 +400,9 @@ export const NewSessionItem = (props: {
|
||||
<A
|
||||
href={`/${props.slug}/session`}
|
||||
end
|
||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center gap-1 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()
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, 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 { displayName, sortedRootSessions } from "./helpers"
|
||||
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
||||
|
||||
export type ProjectSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -31,7 +32,8 @@ 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" | "mobile" | "dense">
|
||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
|
||||
setHoverSession: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
export const ProjectDragOverlay = (props: {
|
||||
@@ -53,6 +55,7 @@ export const ProjectDragOverlay = (props: {
|
||||
const ProjectTile = (props: {
|
||||
project: LocalProject
|
||||
mobile?: boolean
|
||||
nav: Accessor<HTMLElement | undefined>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
selected: Accessor<boolean>
|
||||
active: Accessor<boolean>
|
||||
@@ -192,7 +195,9 @@ 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 => (
|
||||
@@ -213,8 +218,9 @@ const ProjectPreviewPanel = (props: {
|
||||
list={props.projectSessions()}
|
||||
slug={base64Encode(props.project.worktree)}
|
||||
dense
|
||||
showTooltip
|
||||
mobile={props.mobile}
|
||||
popover={false}
|
||||
children={props.projectChildren()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -223,6 +229,7 @@ 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">
|
||||
@@ -239,8 +246,9 @@ const ProjectPreviewPanel = (props: {
|
||||
list={sessions()}
|
||||
slug={base64Encode(directory)}
|
||||
dense
|
||||
showTooltip
|
||||
mobile={props.mobile}
|
||||
popover={false}
|
||||
children={children()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -302,14 +310,20 @@ 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}
|
||||
@@ -346,6 +360,7 @@ 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
|
||||
@@ -356,7 +371,9 @@ export const SortableProject = (props: {
|
||||
workspaces={workspaces}
|
||||
label={label}
|
||||
projectSessions={projectSessions}
|
||||
projectChildren={projectChildren}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceChildren={workspaceChildren}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
@@ -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 { sortedRootSessions, workspaceKey } from "./helpers"
|
||||
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
|
||||
|
||||
type InlineEditorComponent = (props: {
|
||||
id: string
|
||||
@@ -35,6 +35,9 @@ 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>
|
||||
@@ -149,6 +152,7 @@ const WorkspaceActions = (props: {
|
||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||
root: string
|
||||
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
|
||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||
navigateToNewSession: () => void
|
||||
}): JSX.Element => (
|
||||
@@ -222,6 +226,7 @@ const WorkspaceActions = (props: {
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.setHoverSession(undefined)
|
||||
props.clearHoverProjectSoon()
|
||||
props.navigateToNewSession()
|
||||
}}
|
||||
@@ -234,10 +239,12 @@ 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>
|
||||
@@ -249,6 +256,7 @@ const WorkspaceSessionList = (props: {
|
||||
mobile={props.mobile}
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
setHoverSession={props.ctx.setHoverSession}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.loading()}>
|
||||
@@ -262,8 +270,13 @@ const WorkspaceSessionList = (props: {
|
||||
navList={props.ctx.navList}
|
||||
slug={props.slug()}
|
||||
mobile={props.mobile}
|
||||
showChild
|
||||
popover={props.popover}
|
||||
children={props.children()}
|
||||
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}
|
||||
@@ -294,6 +307,7 @@ export const SortableWorkspace = (props: {
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
}): JSX.Element => {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
@@ -307,6 +321,7 @@ 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(() => {
|
||||
@@ -413,6 +428,7 @@ 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`)}
|
||||
/>
|
||||
@@ -424,10 +440,12 @@ 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}
|
||||
@@ -443,6 +461,7 @@ export const LocalWorkspace = (props: {
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
@@ -452,6 +471,7 @@ 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)
|
||||
@@ -469,10 +489,12 @@ 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}
|
||||
|
||||
@@ -429,7 +429,6 @@ 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)
|
||||
@@ -1059,7 +1058,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
if (composer.blocked() || isChildSession()) return
|
||||
if (composer.blocked()) return
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
@@ -1128,10 +1127,7 @@ export default function Page() {
|
||||
setFileTreeTab("all")
|
||||
}
|
||||
|
||||
const focusInput = () => {
|
||||
if (isChildSession()) return
|
||||
inputRef?.focus()
|
||||
}
|
||||
const focusInput = () => inputRef?.focus()
|
||||
|
||||
useSessionCommands({
|
||||
navigateMessageByOffset,
|
||||
@@ -1662,7 +1658,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() && !isChildSession()
|
||||
return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
|
||||
})
|
||||
|
||||
const followupText = (item: FollowupDraft) => {
|
||||
@@ -1694,7 +1690,6 @@ 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()
|
||||
@@ -1825,7 +1820,6 @@ 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
|
||||
|
||||
@@ -2007,7 +2001,7 @@ export default function Page() {
|
||||
}}
|
||||
onResponseSubmit={resumeScroll}
|
||||
followup={
|
||||
params.id && !isChildSession()
|
||||
params.id
|
||||
? {
|
||||
queue: queueEnabled,
|
||||
items: followupDock(),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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"
|
||||
@@ -45,17 +43,11 @@ 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
|
||||
@@ -121,12 +113,6 @@ 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
|
||||
@@ -170,7 +156,7 @@ export function SessionComposerRegion(props: {
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={showComposer()}>
|
||||
<Show when={!props.state.blocked()}>
|
||||
<Show
|
||||
when={prompt.ready()}
|
||||
fallback={
|
||||
@@ -246,40 +232,17 @@ export function SessionComposerRegion(props: {
|
||||
onEdit={props.followup!.onEdit}
|
||||
/>
|
||||
</Show>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -21,7 +21,6 @@ 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"
|
||||
@@ -69,16 +68,6 @@ 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]")
|
||||
@@ -306,32 +295,6 @@ 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({
|
||||
@@ -354,20 +317,8 @@ 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()
|
||||
@@ -447,20 +398,8 @@ 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() || parentID()) return
|
||||
if (!sessionID()) return
|
||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
@@ -699,53 +638,27 @@ 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={{
|
||||
@@ -763,16 +676,15 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show when={titleLabel() || 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}
|
||||
>
|
||||
{childTitle()}
|
||||
{titleLabel()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
@@ -780,7 +692,6 @@ 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]"
|
||||
@@ -808,179 +719,177 @@ export function MessageTimeline(props: {
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID()}>
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"bg-surface-base-active": share.open || title.pendingShare,
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||
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)
|
||||
})
|
||||
}
|
||||
}}
|
||||
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.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.share.action.share")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
<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)
|
||||
}}
|
||||
</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()} />)}
|
||||
>
|
||||
<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>
|
||||
<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="mt-3 flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<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={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
{language.t("session.share.action.view")}
|
||||
</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>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
</Show>
|
||||
</div>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -5,30 +5,9 @@ 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()] ?? tone(name.toLowerCase())
|
||||
return defaults[name] ?? defaults[name.toLowerCase()]
|
||||
}
|
||||
|
||||
export function messageAgentColor(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -90,8 +90,7 @@ export async function handler(
|
||||
const body = await input.request.json()
|
||||
const model = opts.parseModel(url, body)
|
||||
const isStream = opts.parseIsStream(url, body)
|
||||
const rawIp = input.request.headers.get("x-real-ip") ?? ""
|
||||
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
|
||||
const ip = input.request.headers.get("x-real-ip") ?? ""
|
||||
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
||||
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
|
||||
@@ -17,8 +17,9 @@ export function createRateLimiter(
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const limits = Subscription.getFreeLimits()
|
||||
const dailyLimit = rateLimit ?? limits.dailyRequests
|
||||
const isDefaultModel = !rateLimit
|
||||
const headerExists = request.headers.has(limits.checkHeader)
|
||||
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests)
|
||||
const isDefaultModel = headerExists && !rateLimit
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -9,6 +9,8 @@ export namespace Subscription {
|
||||
free: z.object({
|
||||
promoTokens: z.number().int(),
|
||||
dailyRequests: z.number().int(),
|
||||
checkHeader: z.string(),
|
||||
fallbackValue: z.number().int(),
|
||||
}),
|
||||
lite: z.object({
|
||||
rollingLimit: z.number().int(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.17"
|
||||
version = "1.3.15"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -54,7 +54,6 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
@@ -136,7 +135,6 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.1",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
|
||||
@@ -21,9 +21,6 @@ import {
|
||||
type Role,
|
||||
type SessionInfo,
|
||||
type SetSessionModelRequest,
|
||||
type SessionConfigOption,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
type ToolCallContent,
|
||||
@@ -604,7 +601,6 @@ export namespace ACP {
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
configOptions: load.configOptions,
|
||||
models: load.models,
|
||||
modes: load.modes,
|
||||
_meta: load._meta,
|
||||
@@ -664,11 +660,6 @@ export namespace ACP {
|
||||
result.modes.currentModeId = lastUser.agent
|
||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||
}
|
||||
result.configOptions = buildConfigOptions({
|
||||
currentModelId: result.models.currentModelId,
|
||||
availableModels: result.models.availableModels,
|
||||
modes: result.modes,
|
||||
})
|
||||
}
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
@@ -1275,11 +1266,6 @@ export namespace ACP {
|
||||
availableModels,
|
||||
},
|
||||
modes,
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
|
||||
availableModels,
|
||||
modes,
|
||||
}),
|
||||
_meta: buildVariantMeta({
|
||||
model,
|
||||
variant: this.sessionManager.getVariant(sessionId),
|
||||
@@ -1319,44 +1305,6 @@ export namespace ACP {
|
||||
this.sessionManager.setMode(params.sessionId, params.modeId)
|
||||
}
|
||||
|
||||
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
const providers = await this.sdk.config
|
||||
.providers({ directory: session.cwd }, { throwOnError: true })
|
||||
.then((x) => x.data!.providers)
|
||||
const entries = sortProvidersByName(providers)
|
||||
|
||||
if (params.configId === "model") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
|
||||
const selection = parseModelSelection(params.value, providers)
|
||||
this.sessionManager.setModel(session.id, selection.model)
|
||||
this.sessionManager.setVariant(session.id, selection.variant)
|
||||
} else if (params.configId === "mode") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
|
||||
const availableModes = await this.loadAvailableModes(session.cwd)
|
||||
if (!availableModes.some((mode) => mode.id === params.value)) {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
|
||||
}
|
||||
this.sessionManager.setMode(session.id, params.value)
|
||||
} else {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
|
||||
}
|
||||
|
||||
const updatedSession = this.sessionManager.get(session.id)
|
||||
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, model)
|
||||
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
|
||||
const availableModels = buildAvailableModels(entries, { includeVariants: true })
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest) {
|
||||
const sessionID = params.sessionId
|
||||
const session = this.sessionManager.get(sessionID)
|
||||
@@ -1812,36 +1760,4 @@ export namespace ACP {
|
||||
|
||||
return { model: parsed, variant: undefined }
|
||||
}
|
||||
|
||||
function buildConfigOptions(input: {
|
||||
currentModelId: string
|
||||
availableModels: ModelOption[]
|
||||
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
||||
}): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: input.currentModelId,
|
||||
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
|
||||
},
|
||||
]
|
||||
if (input.modes) {
|
||||
options.push({
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.modes.currentModeId,
|
||||
options: input.modes.availableModes.map((m) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
...(m.description ? { description: m.description } : {}),
|
||||
})),
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export namespace Agent {
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
|
||||
@@ -24,7 +24,6 @@ export namespace Auth {
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
|
||||
@@ -47,7 +47,7 @@ export namespace Bus {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Bus.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
|
||||
|
||||
@@ -125,17 +125,14 @@ import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||
|
||||
return {
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
useMouse: mouseEnabled,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
@@ -761,7 +758,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
keybind: "terminal_suspend",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
enabled: tuiConfig.keybinds?.terminal_suspend !== "none",
|
||||
onSelect: () => {
|
||||
process.once("SIGCONT", () => {
|
||||
renderer.resume()
|
||||
|
||||
@@ -129,15 +129,7 @@ export function createDialogProviderOptions() {
|
||||
}
|
||||
}
|
||||
if (method.type === "api") {
|
||||
let metadata: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({ dialog, prompts: method.prompts })
|
||||
if (!value) return
|
||||
metadata = value
|
||||
}
|
||||
return dialog.replace(() => (
|
||||
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
|
||||
))
|
||||
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -257,7 +249,6 @@ function CodeMethod(props: CodeMethodProps) {
|
||||
interface ApiMethodProps {
|
||||
providerID: string
|
||||
title: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
@@ -302,7 +293,6 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
auth: {
|
||||
type: "api",
|
||||
key: value,
|
||||
...(props.metadata ? { metadata: props.metadata } : {}),
|
||||
},
|
||||
})
|
||||
await sdk.client.instance.dispose()
|
||||
|
||||
@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useRenderer, type JSX } from "@opentui/solid"
|
||||
import { useKeyboard, useRenderer, type JSX } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
@@ -400,6 +400,20 @@ export function Prompt(props: PromptProps) {
|
||||
]
|
||||
})
|
||||
|
||||
// Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are
|
||||
// enabled, but still reports the kitty key-release event. Probe on release.
|
||||
if (process.platform === "win32") {
|
||||
useKeyboard(
|
||||
(evt) => {
|
||||
if (!input.focused) return
|
||||
if (evt.name === "v" && evt.ctrl && evt.eventType === "release") {
|
||||
command.trigger("prompt.paste")
|
||||
}
|
||||
},
|
||||
{ release: true },
|
||||
)
|
||||
}
|
||||
|
||||
const ref: PromptRef = {
|
||||
get focused() {
|
||||
return input.focused
|
||||
|
||||
@@ -148,7 +148,5 @@ const TIPS = [
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
|
||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
|
||||
"Use {highlight}/rename{/highlight} to rename the current session",
|
||||
...(process.platform === "win32"
|
||||
? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"]
|
||||
: ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]),
|
||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
|
||||
]
|
||||
|
||||
@@ -79,7 +79,7 @@ export namespace Command {
|
||||
const mcp = yield* MCP.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const init = Effect.fn("Command.state")(function* (ctx) {
|
||||
const init = Effect.fnUntraced(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const commands: Record<string, Info> = {}
|
||||
|
||||
|
||||
@@ -1475,7 +1475,7 @@ export namespace Config {
|
||||
})
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Config.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
return yield* loadInstanceState(ctx)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ export const TuiOptions = z.object({
|
||||
.enum(["auto", "stacked"])
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
|
||||
})
|
||||
|
||||
export const TuiInfo = z
|
||||
|
||||
@@ -111,15 +111,7 @@ export namespace TuiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
|
||||
",",
|
||||
)
|
||||
}
|
||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
||||
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (acc.result.plugin?.length) {
|
||||
|
||||
26
packages/opencode/src/effect/observability.ts
Normal file
26
packages/opencode/src/effect/observability.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Otlp } from "effect/unstable/observability"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { CHANNEL, VERSION } from "@/installation/meta"
|
||||
|
||||
export namespace Observability {
|
||||
const base = Flag.OPENCODE_OTLP_BASE_URL?.trim() || undefined
|
||||
|
||||
export const enabled = !!base
|
||||
|
||||
export const layer = !base
|
||||
? Layer.empty
|
||||
: Otlp.layerJson({
|
||||
baseUrl: base,
|
||||
loggerMergeWithExisting: false,
|
||||
resource: {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: VERSION,
|
||||
attributes: {
|
||||
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
},
|
||||
},
|
||||
}).pipe(Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as ServiceMap from "effect/ServiceMap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { Observability } from "./observability"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
@@ -18,7 +19,7 @@ function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
||||
|
||||
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
||||
const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))
|
||||
|
||||
return {
|
||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),
|
||||
|
||||
@@ -346,11 +346,11 @@ export namespace File {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
Effect.succeed({
|
||||
Effect.fnUntraced(function* () {
|
||||
return {
|
||||
cache: { files: [], dirs: [] } as Entry,
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const scan = Effect.fn("File.scan")(function* () {
|
||||
|
||||
@@ -54,12 +54,12 @@ export namespace FileTime {
|
||||
}
|
||||
})
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("FileTime.state")(() =>
|
||||
Effect.succeed({
|
||||
Effect.fnUntraced(function* () {
|
||||
return {
|
||||
reads: new Map<SessionID, Map<string, Stamp>>(),
|
||||
locks: new Map<string, Semaphore.Semaphore>(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
||||
|
||||
@@ -73,7 +73,7 @@ export namespace FileWatcher {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
Effect.fnUntraced(
|
||||
function* () {
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export namespace Flag {
|
||||
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
||||
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
|
||||
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
|
||||
export const OPENCODE_DISABLE_MOUSE = truthy("OPENCODE_DISABLE_MOUSE")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
|
||||
@@ -45,6 +44,7 @@ export namespace Flag {
|
||||
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
|
||||
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
|
||||
export const OPENCODE_OTLP_BASE_URL = process.env["OPENCODE_OTLP_BASE_URL"]
|
||||
|
||||
// Experimental
|
||||
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
|
||||
@@ -40,7 +40,7 @@ export namespace Format {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Format.state")(function* (_ctx) {
|
||||
Effect.fnUntraced(function* (_ctx) {
|
||||
const commands: Record<string, string[] | false> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
@@ -84,57 +84,106 @@ export namespace Format {
|
||||
return cmd !== false
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
||||
const checks = await Promise.all(
|
||||
matching.map(async (item) => {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
const cmd = await getCommand(item)
|
||||
if (cmd) {
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
}
|
||||
return {
|
||||
item,
|
||||
cmd,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
|
||||
function check(item: Formatter.Info, ext: string) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
ext,
|
||||
formatter: item.name,
|
||||
})
|
||||
log.info("checking", { name: item.name, ext })
|
||||
const cmd = yield* Effect.promise(() => getCommand(item))
|
||||
if (cmd) {
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
}
|
||||
yield* Effect.annotateCurrentSpan({ enabled: !!cmd })
|
||||
return {
|
||||
item,
|
||||
cmd,
|
||||
}
|
||||
}).pipe(Effect.withSpan("Format.checkFormatter"))
|
||||
}
|
||||
|
||||
function formatFile(filepath: string) {
|
||||
function resolve(ext: string) {
|
||||
return Effect.gen(function* () {
|
||||
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
||||
const checks = yield* Effect.all(matching.map((item) => check(item, ext)))
|
||||
const enabled = checks.filter((item) => item.cmd).map((item) => ({ item: item.item, cmd: item.cmd! }))
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
ext,
|
||||
matched_formatters: matching.map((item) => item.name).join(",") || "none",
|
||||
enabled_formatters: enabled.map((item) => item.item.name).join(",") || "none",
|
||||
})
|
||||
return {
|
||||
matching,
|
||||
enabled,
|
||||
}
|
||||
}).pipe(Effect.withSpan("Format.resolveFormatters"))
|
||||
}
|
||||
|
||||
function spawn(item: Formatter.Info, command: string[], filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
const dir = yield* InstanceState.directory
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
file: filepath,
|
||||
formatter: item.name,
|
||||
command: command.join(" "),
|
||||
})
|
||||
return yield* spawner.spawn(
|
||||
ChildProcess.make(command[0]!, command.slice(1), {
|
||||
cwd: dir,
|
||||
env: item.environment,
|
||||
extendEnv: true,
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.withSpan("Format.spawnFormatter"))
|
||||
}
|
||||
|
||||
function wait(
|
||||
handle: ChildProcessSpawner.ChildProcessHandle,
|
||||
item: Formatter.Info,
|
||||
command: string[],
|
||||
filepath: string,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
file: filepath,
|
||||
formatter: item.name,
|
||||
command: command.join(" "),
|
||||
})
|
||||
return yield* handle.exitCode
|
||||
}).pipe(Effect.withSpan("Format.waitFormatter"))
|
||||
}
|
||||
|
||||
function formatFile(filepath: string): Effect.Effect<void, never, never> {
|
||||
return Effect.gen(function* () {
|
||||
log.info("formatting", { file: filepath })
|
||||
const ext = path.extname(filepath)
|
||||
yield* Effect.annotateCurrentSpan({ file: filepath, ext })
|
||||
const fmt = yield* resolve(ext)
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
matched_formatters: fmt.matching.map((item) => item.name).join(",") || "none",
|
||||
enabled_formatters: fmt.enabled.map((item) => item.item.name).join(",") || "none",
|
||||
})
|
||||
|
||||
for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) {
|
||||
for (const { item, cmd } of fmt.enabled) {
|
||||
if (cmd === false) continue
|
||||
log.info("running", { command: cmd })
|
||||
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
|
||||
const dir = yield* InstanceState.directory
|
||||
const code = yield* spawner
|
||||
.spawn(
|
||||
ChildProcess.make(replaced[0]!, replaced.slice(1), {
|
||||
cwd: dir,
|
||||
env: item.environment,
|
||||
extendEnv: true,
|
||||
const code = yield* spawn(item, replaced, filepath).pipe(
|
||||
Effect.flatMap((handle) => wait(handle, item, replaced, filepath)),
|
||||
Effect.scoped,
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to format file", {
|
||||
error: "spawn failed",
|
||||
command: replaced,
|
||||
...item.environment,
|
||||
file: filepath,
|
||||
})
|
||||
return ChildProcessSpawner.ExitCode(1)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((handle) => handle.exitCode),
|
||||
Effect.scoped,
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to format file", {
|
||||
error: "spawn failed",
|
||||
command: cmd,
|
||||
...item.environment,
|
||||
file: filepath,
|
||||
})
|
||||
return ChildProcessSpawner.ExitCode(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
if (code !== 0) {
|
||||
log.error("failed", {
|
||||
command: cmd,
|
||||
|
||||
@@ -164,7 +164,7 @@ export namespace LSP {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* () {
|
||||
Effect.fnUntraced(function* () {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
@@ -105,17 +105,7 @@ export namespace LSPServer {
|
||||
if (!tsserver) return
|
||||
const bin = await Npm.which("typescript-language-server")
|
||||
if (!bin) return
|
||||
|
||||
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
|
||||
|
||||
if (
|
||||
!(await pathExists(path.join(root, "tsconfig.json"))) &&
|
||||
!(await pathExists(path.join(root, "jsconfig.json")))
|
||||
) {
|
||||
args.push("--ignore-node-modules")
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
const proc = spawn(bin, ["--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -478,7 +478,7 @@ export namespace MCP {
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("MCP.state")(function* () {
|
||||
Effect.fnUntraced(function* () {
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const s: State = {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Arborist } from "@npmcli/arborist"
|
||||
|
||||
export namespace Npm {
|
||||
const log = Log.create({ service: "npm" })
|
||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"NpmInstallFailedError",
|
||||
@@ -20,13 +19,8 @@ export namespace Npm {
|
||||
}),
|
||||
)
|
||||
|
||||
export function sanitize(pkg: string) {
|
||||
if (!illegal) return pkg
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
function directory(pkg: string) {
|
||||
return path.join(Global.Path.cache, "packages", sanitize(pkg))
|
||||
return path.join(Global.Path.cache, "packages", pkg)
|
||||
}
|
||||
|
||||
function resolveEntryPoint(name: string, dir: string) {
|
||||
|
||||
@@ -142,7 +142,7 @@ export namespace Permission {
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Permission.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
||||
)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = [
|
||||
...(!process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "cloudflare-workers-ai",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = [
|
||||
...(!process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!process.env.CLOUDFLARE_GATEWAY_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "gatewayId",
|
||||
message: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "cloudflare-ai-gateway",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "Gateway API token",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
@@ -47,14 +46,7 @@ export namespace Plugin {
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CodexAuthPlugin,
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
]
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
@@ -106,7 +98,7 @@ export namespace Plugin {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Plugin.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const hooks: Hooks[] = []
|
||||
|
||||
const { Server } = yield* Effect.promise(() => import("../server/server"))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import npa from "npm-package-arg"
|
||||
import semver from "semver"
|
||||
import { Npm } from "@/npm"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -13,24 +12,11 @@ export function isDeprecatedPlugin(spec: string) {
|
||||
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
|
||||
}
|
||||
|
||||
function parse(spec: string) {
|
||||
try {
|
||||
return npa(spec)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function parsePluginSpecifier(spec: string) {
|
||||
const hit = parse(spec)
|
||||
if (hit?.type === "alias" && !hit.name) {
|
||||
const sub = (hit as npa.AliasResult).subSpec
|
||||
if (sub?.name) {
|
||||
const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec
|
||||
return { pkg: sub.name, version }
|
||||
}
|
||||
}
|
||||
if (!hit?.name) return { pkg: spec, version: "" }
|
||||
if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" }
|
||||
return { pkg: hit.name, version: hit.rawSpec }
|
||||
const lastAt = spec.lastIndexOf("@")
|
||||
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
|
||||
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
|
||||
return { pkg, version }
|
||||
}
|
||||
|
||||
export type PluginSource = "file" | "npm"
|
||||
@@ -204,11 +190,9 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePluginTarget(spec: string) {
|
||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
|
||||
const hit = parse(spec)
|
||||
const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec
|
||||
const result = await Npm.add(pkg)
|
||||
const result = await Npm.add(parsed.pkg + "@" + parsed.version)
|
||||
return result.directory
|
||||
}
|
||||
|
||||
|
||||
@@ -147,39 +147,37 @@ export namespace Vcs {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
return yield* git.branch(ctx.directory)
|
||||
})
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
return yield* git.branch(ctx.directory)
|
||||
})
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return value
|
||||
}),
|
||||
),
|
||||
return value
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -117,7 +117,7 @@ export namespace ProviderAuth {
|
||||
const auth = yield* Auth.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ProviderAuth.state")(function* () {
|
||||
Effect.fnUntraced(function* () {
|
||||
const plugins = yield* plugin.list()
|
||||
return {
|
||||
hooks: Record.fromEntries(
|
||||
|
||||
@@ -672,26 +672,13 @@ export namespace Provider {
|
||||
}
|
||||
}),
|
||||
"cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
|
||||
// When baseURL is already configured (e.g. corporate config routing through a proxy/gateway),
|
||||
// skip the account ID check because the URL is already fully specified.
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const accountId =
|
||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
if (!accountId)
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
"CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>",
|
||||
)
|
||||
},
|
||||
}
|
||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
if (!accountId) return { autoload: false }
|
||||
|
||||
const apiKey = yield* Effect.gen(function* () {
|
||||
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
||||
if (envToken) return envToken
|
||||
const auth = yield* dep.auth(input.id)
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
})
|
||||
@@ -715,34 +702,16 @@ export namespace Provider {
|
||||
}
|
||||
}),
|
||||
"cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
|
||||
// When baseURL is already configured (e.g. corporate config), skip the ID checks.
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const accountId =
|
||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
const gateway =
|
||||
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
||||
|
||||
if (!accountId || !gateway) {
|
||||
const missing = [
|
||||
!accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined,
|
||||
!gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined,
|
||||
].filter((x): x is string => Boolean(x))
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
`${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (!accountId || !gateway) return { autoload: false }
|
||||
|
||||
// Get API token from env or auth - required for authenticated gateways
|
||||
const apiToken = yield* Effect.gen(function* () {
|
||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
||||
if (envToken) return envToken
|
||||
const auth = yield* dep.auth(input.id)
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -936,12 +936,6 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
const key = sdkKey(model.api.npm) ?? model.providerID
|
||||
// @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
|
||||
// providerOptions["openai"], but OpenAIResponsesLanguageModel checks
|
||||
// "azure" first. Pass both so model options work on either code path.
|
||||
if (model.api.npm === "@ai-sdk/azure") {
|
||||
return { openai: options, azure: options }
|
||||
}
|
||||
return { [key]: options }
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export namespace Pty {
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Pty.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const state = {
|
||||
dir: ctx.directory,
|
||||
sessions: new Map<PtyID, Active>(),
|
||||
|
||||
@@ -111,7 +111,7 @@ export namespace Question {
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Question.state")(function* () {
|
||||
Effect.fnUntraced(function* () {
|
||||
const state = {
|
||||
pending: new Map<QuestionID, PendingEntry>(),
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export namespace SessionCompaction {
|
||||
}
|
||||
})
|
||||
|
||||
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
|
||||
const process = Effect.fn("SessionCompaction.process")(function* (input: {
|
||||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
@@ -374,7 +374,7 @@ When constructing the summary, try to stick to this template:
|
||||
return Service.of({
|
||||
isOverflow,
|
||||
prune,
|
||||
process: processCompaction,
|
||||
process,
|
||||
create,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -75,12 +75,12 @@ export namespace Instruction {
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Instruction.state")(() =>
|
||||
Effect.succeed({
|
||||
Effect.fnUntraced(function* () {
|
||||
return {
|
||||
// Track which instruction files have already been attached for a given assistant message.
|
||||
claims: new Map<MessageID, Set<string>>(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const relative = Effect.fnUntraced(function* (instruction: string) {
|
||||
|
||||
@@ -415,9 +415,20 @@ export namespace SessionProcessor {
|
||||
|
||||
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
|
||||
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
|
||||
yield* Effect.logError("session processor failed", {
|
||||
agent: ctx.assistantMessage.agent,
|
||||
modelID: ctx.model.id,
|
||||
providerID: ctx.model.providerID,
|
||||
sessionID: ctx.sessionID,
|
||||
})
|
||||
const error = parse(e)
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||
ctx.needsCompaction = true
|
||||
yield* Effect.logWarning("session processor requested compaction", {
|
||||
modelID: ctx.model.id,
|
||||
providerID: ctx.model.providerID,
|
||||
sessionID: ctx.sessionID,
|
||||
})
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
||||
return
|
||||
}
|
||||
@@ -446,6 +457,18 @@ export namespace SessionProcessor {
|
||||
log.info("process")
|
||||
ctx.needsCompaction = false
|
||||
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
agent: streamInput.agent.name,
|
||||
modelID: streamInput.model.id,
|
||||
providerID: streamInput.model.providerID,
|
||||
sessionID: ctx.sessionID,
|
||||
})
|
||||
yield* Effect.logInfo("session processor started", {
|
||||
agent: streamInput.agent.name,
|
||||
modelID: streamInput.model.id,
|
||||
providerID: streamInput.model.providerID,
|
||||
sessionID: ctx.sessionID,
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -459,6 +482,7 @@ export namespace SessionProcessor {
|
||||
Stream.runDrain,
|
||||
)
|
||||
}).pipe(
|
||||
Effect.withSpan("SessionProcessor.stream"),
|
||||
Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
@@ -483,6 +507,12 @@ export namespace SessionProcessor {
|
||||
if (aborted && !ctx.assistantMessage.error) {
|
||||
yield* abort()
|
||||
}
|
||||
yield* Effect.logInfo("session processor finished", {
|
||||
aborted,
|
||||
blocked: ctx.blocked,
|
||||
compact: ctx.needsCompaction,
|
||||
sessionID: ctx.sessionID,
|
||||
})
|
||||
if (ctx.needsCompaction) return "compact"
|
||||
if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop"
|
||||
return "continue"
|
||||
|
||||
@@ -103,7 +103,7 @@ export namespace SessionPrompt {
|
||||
const instruction = yield* Instruction.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionPrompt.state")(function* () {
|
||||
Effect.fnUntraced(function* () {
|
||||
const runners = new Map<string, Runner<MessageV2.WithParts>>()
|
||||
yield* Effect.addFinalizer(
|
||||
Effect.fnUntraced(function* () {
|
||||
@@ -1340,12 +1340,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
let structured: unknown | undefined
|
||||
let step = 0
|
||||
const session = yield* sessions.get(sessionID)
|
||||
yield* Effect.annotateCurrentSpan({ sessionID })
|
||||
|
||||
while (true) {
|
||||
yield* status.set(sessionID, { type: "busy" })
|
||||
log.info("loop", { step, sessionID })
|
||||
|
||||
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
|
||||
let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(
|
||||
Effect.withSpan("SessionPrompt.loadMessages"),
|
||||
)
|
||||
|
||||
let lastUser: MessageV2.User | undefined
|
||||
let lastAssistant: MessageV2.Assistant | undefined
|
||||
@@ -1398,13 +1401,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
if (task?.type === "compaction") {
|
||||
const result = yield* compaction.process({
|
||||
messages: msgs,
|
||||
parentID: lastUser.id,
|
||||
sessionID,
|
||||
yield* Effect.logWarning("session compaction task", {
|
||||
auto: task.auto,
|
||||
overflow: task.overflow,
|
||||
sessionID,
|
||||
})
|
||||
const result = yield* compaction
|
||||
.process({
|
||||
messages: msgs,
|
||||
parentID: lastUser.id,
|
||||
sessionID,
|
||||
auto: task.auto,
|
||||
overflow: task.overflow,
|
||||
})
|
||||
.pipe(Effect.withSpan("SessionPrompt.compaction"))
|
||||
if (result === "stop") break
|
||||
continue
|
||||
}
|
||||
@@ -1414,6 +1424,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
lastFinished.summary !== true &&
|
||||
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
|
||||
) {
|
||||
yield* Effect.logWarning("session overflow detected", { modelID: model.id, sessionID, step })
|
||||
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
|
||||
continue
|
||||
}
|
||||
@@ -1429,6 +1440,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const maxSteps = agent.steps ?? Infinity
|
||||
const isLastStep = step >= maxSteps
|
||||
msgs = yield* insertReminders({ messages: msgs, agent, session })
|
||||
yield* Effect.logInfo("session turn", {
|
||||
agent: agent.name,
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
sessionID,
|
||||
step,
|
||||
})
|
||||
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
@@ -1503,7 +1521,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
Effect.promise(() => SystemPrompt.environment(model)),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
|
||||
])
|
||||
]).pipe(Effect.withSpan("SessionPrompt.buildInput"))
|
||||
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
||||
const format = lastUser.format ?? { type: "text" as const }
|
||||
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
|
||||
@@ -58,7 +58,9 @@ export namespace SessionStatus {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
|
||||
Effect.fnUntraced(function* () {
|
||||
return new Map<SessionID, Info>()
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
|
||||
|
||||
@@ -99,8 +99,8 @@ export namespace SessionSummary {
|
||||
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
|
||||
}
|
||||
}
|
||||
if (from && to) return yield* snapshot.diffFull(from, to)
|
||||
return []
|
||||
if (!from || !to || from === to) return []
|
||||
return yield* snapshot.diffFull(from, to)
|
||||
})
|
||||
|
||||
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
|
||||
|
||||
@@ -144,7 +144,7 @@ export namespace ShareNext {
|
||||
}
|
||||
|
||||
const state: InstanceState<State> = yield* InstanceState.make<State>(
|
||||
Effect.fn("ShareNext.state")(function* (_ctx) {
|
||||
Effect.fnUntraced(function* (_ctx) {
|
||||
const cache: State = { queue: new Map(), scope: yield* Scope.make() }
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
|
||||
@@ -197,7 +197,7 @@ export namespace Skill {
|
||||
const bus = yield* Bus.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Skill.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const s: State = { skills: {}, dirs: new Set() }
|
||||
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
|
||||
return s
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
@@ -82,7 +81,7 @@ export namespace Snapshot {
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
@@ -150,7 +149,7 @@ export namespace Snapshot {
|
||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
const add = Effect.fn("Snapshot.add")(function* () {
|
||||
yield* sync()
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
@@ -203,7 +202,7 @@ export namespace Snapshot {
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
@@ -221,7 +220,7 @@ export namespace Snapshot {
|
||||
)
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
const track = Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
@@ -238,7 +237,9 @@ export namespace Snapshot {
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory }).pipe(
|
||||
Effect.withSpan("Snapshot.writeTree"),
|
||||
)
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
@@ -246,7 +247,7 @@ export namespace Snapshot {
|
||||
)
|
||||
})
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
@@ -273,7 +274,7 @@ export namespace Snapshot {
|
||||
)
|
||||
})
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
@@ -299,7 +300,7 @@ export namespace Snapshot {
|
||||
)
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const ops: { hash: string; file: string; rel: string }[] = []
|
||||
@@ -414,7 +415,7 @@ export namespace Snapshot {
|
||||
)
|
||||
})
|
||||
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
@@ -434,7 +435,7 @@ export namespace Snapshot {
|
||||
)
|
||||
})
|
||||
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
type Row = {
|
||||
@@ -451,7 +452,7 @@ export namespace Snapshot {
|
||||
ref: string
|
||||
}
|
||||
|
||||
const show = Effect.fnUntraced(function* (row: Row) {
|
||||
const show = Effect.fn("Snapshot.show")(function* (row: Row) {
|
||||
if (row.binary) return ["", ""]
|
||||
if (row.status === "added") {
|
||||
return [
|
||||
@@ -478,7 +479,7 @@ export namespace Snapshot {
|
||||
)
|
||||
})
|
||||
|
||||
const load = Effect.fnUntraced(
|
||||
const load = Effect.fn("Snapshot.load")(
|
||||
function* (rows: Row[]) {
|
||||
const refs = rows.flatMap((row) => {
|
||||
if (row.binary) return []
|
||||
@@ -583,7 +584,7 @@ export namespace Snapshot {
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
).pipe(Effect.withSpan("Snapshot.diffStatus"))
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
@@ -597,7 +598,7 @@ export namespace Snapshot {
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
).pipe(Effect.withSpan("Snapshot.diffNumstat"))
|
||||
|
||||
const rows = numstat.text
|
||||
.trim()
|
||||
@@ -660,30 +661,14 @@ export namespace Snapshot {
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Snapshot.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
track: Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||
}),
|
||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||
}),
|
||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||
}),
|
||||
init: () => InstanceState.get(state).pipe(Effect.asVoid),
|
||||
cleanup: () => InstanceState.useEffect(state, (s) => s.cleanup()),
|
||||
track: () => InstanceState.useEffect(state, (s) => s.track()),
|
||||
patch: (hash: string) => InstanceState.useEffect(state, (s) => s.patch(hash)),
|
||||
restore: (snapshot: string) => InstanceState.useEffect(state, (s) => s.restore(snapshot)),
|
||||
revert: (patches: Snapshot.Patch[]) => InstanceState.useEffect(state, (s) => s.revert(patches)),
|
||||
diff: (hash: string) => InstanceState.useEffect(state, (s) => s.diff(hash)),
|
||||
diffFull: (from: string, to: string) => InstanceState.useEffect(state, (s) => s.diffFull(from, to)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -185,7 +185,7 @@ export const ReadTool = Tool.defineEffect(
|
||||
)
|
||||
}
|
||||
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>" + "\n"].join("\n")
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
|
||||
|
||||
const last = file.offset + file.raw.length - 1
|
||||
|
||||
@@ -82,7 +82,7 @@ export namespace ToolRegistry {
|
||||
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
Effect.fnUntraced(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
|
||||
beforeEach(async () => {
|
||||
await Config.invalidate(true)
|
||||
@@ -442,53 +441,6 @@ test("merges keybind overrides across precedence layers", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
wintest("keeps explicit input undo overrides on Windows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+y")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
wintest("ignores terminal suspend bindings on Windows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } }))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import * as Lsp from "../../src/lsp/index"
|
||||
import * as launch from "../../src/lsp/launch"
|
||||
import { LSPServer } from "../../src/lsp/server"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -54,80 +52,4 @@ describe("lsp.spawn", () => {
|
||||
await Instance.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
test("spawns builtin Typescript LSP with correct arguments", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
// Create dummy tsserver to satisfy Module.resolve
|
||||
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
||||
await fs.mkdir(tsdk, { recursive: true })
|
||||
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
||||
|
||||
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
stdin: {},
|
||||
stdout: {},
|
||||
stderr: {},
|
||||
on: () => {},
|
||||
kill: () => {},
|
||||
}) as any,
|
||||
)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await LSPServer.Typescript.spawn(tmp.path)
|
||||
},
|
||||
})
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalled()
|
||||
const args = spawnSpy.mock.calls[0][1] as string[]
|
||||
|
||||
expect(args).toContain("--tsserver-path")
|
||||
expect(args).toContain("--tsserver-log-verbosity")
|
||||
expect(args).toContain("off")
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
// Create dummy tsserver to satisfy Module.resolve
|
||||
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
||||
await fs.mkdir(tsdk, { recursive: true })
|
||||
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
||||
|
||||
// NO tsconfig.json or jsconfig.json created here
|
||||
|
||||
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
stdin: {},
|
||||
stdout: {},
|
||||
stderr: {},
|
||||
on: () => {},
|
||||
kill: () => {},
|
||||
}) as any,
|
||||
)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await LSPServer.Typescript.spawn(tmp.path)
|
||||
},
|
||||
})
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalled()
|
||||
const args = spawnSpy.mock.calls[0][1] as string[]
|
||||
|
||||
expect(args).toContain("--ignore-node-modules")
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Npm } from "../src/npm"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
|
||||
expect(Npm.sanitize("prettier")).toBe("prettier")
|
||||
})
|
||||
|
||||
test("handles git https specs", () => {
|
||||
const spec = "acme@git+https://github.com/opencode/acme.git"
|
||||
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
|
||||
expect(Npm.sanitize(spec)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parsePluginSpecifier } from "../../src/plugin/shared"
|
||||
|
||||
describe("parsePluginSpecifier", () => {
|
||||
test("parses standard npm package without version", () => {
|
||||
expect(parsePluginSpecifier("acme")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "latest",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses standard npm package with version", () => {
|
||||
expect(parsePluginSpecifier("acme@1.0.0")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped npm package without version", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "latest",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped npm package with version", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses package with git+https url", () => {
|
||||
expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "git+https://github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped package with git+https url", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "git+https://github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses package with git+ssh url containing another @", () => {
|
||||
expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "git+ssh://git@github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped package with git+ssh url containing another @", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "git+ssh://git@github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses unaliased git+ssh url", () => {
|
||||
expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "git+ssh://git@github.com/opencode/acme.git",
|
||||
version: "",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses npm alias using the alias name", () => {
|
||||
expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "npm:@opencode/acme@1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses bare npm protocol specifier using the target package", () => {
|
||||
expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses unversioned npm protocol specifier", () => {
|
||||
expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "latest",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,7 @@ process.env["XDG_DATA_HOME"] = path.join(dir, "share")
|
||||
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
|
||||
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
|
||||
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
|
||||
delete process.env["OPENCODE_OTLP_BASE_URL"]
|
||||
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
|
||||
|
||||
// Set test home directory to isolate tests from user's actual home directory
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1639,9 +1639,6 @@ export type OAuth = {
|
||||
export type ApiAuth = {
|
||||
type: "api"
|
||||
key: string
|
||||
metadata?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type WellKnownAuth = {
|
||||
|
||||
@@ -11621,15 +11621,6 @@
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -7,21 +7,6 @@
|
||||
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;
|
||||
@@ -180,83 +165,3 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-card"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--background-base) 92%, transparent);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-component="task-tool-spinner"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--icon-weak);
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
[data-component="task-tool-title"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,6 @@ 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 }
|
||||
@@ -124,101 +121,74 @@ export function BasicTool(props: BasicToolProps) {
|
||||
setState("open", value)
|
||||
}
|
||||
|
||||
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-subtitle"
|
||||
classList={{
|
||||
[title().subtitleClass ?? ""]: !!title().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={title().args?.length}>
|
||||
<For each={title().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[title().argsClass ?? ""]: !!title().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</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>
|
||||
</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>
|
||||
<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">
|
||||
<span
|
||||
data-slot="basic-tool-tool-title"
|
||||
classList={{
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={trigger().title} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<Show when={trigger().subtitle}>
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
classList={{
|
||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && trigger().action}>
|
||||
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
|
||||
</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>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
|
||||
@@ -62,11 +62,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&[data-hide-details="true"] {
|
||||
height: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
ReasoningPart,
|
||||
Session,
|
||||
TextPart,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
@@ -50,7 +49,6 @@ 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"
|
||||
@@ -276,47 +274,6 @@ 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) {
|
||||
@@ -445,27 +402,6 @@ 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"])
|
||||
|
||||
@@ -1742,14 +1678,13 @@ ToolRegistry.register({
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const location = useLocation()
|
||||
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 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 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 title = createMemo(() => agentTitle(i18n, type()))
|
||||
const subtitle = createMemo(() => {
|
||||
const value = props.input.description
|
||||
if (typeof value === "string" && value) return value
|
||||
@@ -1758,62 +1693,37 @@ 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 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 titleContent = () => <TextShimmer text={title()} active={running()} />
|
||||
|
||||
const trigger = () => (
|
||||
<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 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>
|
||||
<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
|
||||
triggerHref={href()}
|
||||
clickable={clickable()}
|
||||
onTriggerClick={navigate}
|
||||
/>
|
||||
)
|
||||
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ import { createSimpleContext } from "./helper"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
||||
type Data = {
|
||||
agent?: {
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
provider?: ProviderListResponse
|
||||
session: Session[]
|
||||
session_status: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -573,7 +573,6 @@ OpenCode can be configured using environment variables.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources |
|
||||
| `OPENCODE_DISABLE_MOUSE` | boolean | Disable mouse capture in the TUI |
|
||||
| `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization |
|
||||
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
|
||||
|
||||
@@ -272,8 +272,7 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
},
|
||||
"diff_style": "auto",
|
||||
"mouse": true
|
||||
"diff_style": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -281,6 +280,8 @@ Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
|
||||
|
||||
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
|
||||
|
||||
[Learn more about TUI configuration here](/docs/tui#configure).
|
||||
|
||||
---
|
||||
|
||||
### Server
|
||||
|
||||
@@ -32,7 +32,6 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
||||
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
|
||||
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
|
||||
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
|
||||
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
|
||||
| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Fast Apply editing, WarpGrep codebase search, and context compaction via Morph |
|
||||
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
|
||||
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
|
||||
@@ -43,7 +42,6 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
||||
| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
|
||||
| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control |
|
||||
| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax |
|
||||
| [opencode-conductor](https://github.com/derekbar90/opencode-conductor) | Protocol-Driven Workflow: Automation of the Context -> Spec -> Plan -> Implement lifecycle. |
|
||||
| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity |
|
||||
| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms |
|
||||
| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
|
||||
|
||||
@@ -490,42 +490,37 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
|
||||
|
||||
Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, and more through a unified endpoint. With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) you don't need separate API keys for each provider.
|
||||
|
||||
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway. Note your **Account ID** and **Gateway ID**.
|
||||
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway.
|
||||
|
||||
2. Run the `/connect` command and search for **Cloudflare AI Gateway**.
|
||||
2. Set your Account ID and Gateway ID as environment variables.
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
|
||||
```
|
||||
|
||||
3. Run the `/connect` command and search for **Cloudflare AI Gateway**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Enter your **Account ID** when prompted.
|
||||
4. Enter your Cloudflare API token.
|
||||
|
||||
```txt
|
||||
┌ Enter your Cloudflare Account ID
|
||||
┌ API key
|
||||
│
|
||||
│
|
||||
└ enter
|
||||
```
|
||||
|
||||
4. Enter your **Gateway ID** when prompted.
|
||||
Or set it as an environment variable.
|
||||
|
||||
```txt
|
||||
┌ Enter your Cloudflare AI Gateway ID
|
||||
│
|
||||
│
|
||||
└ enter
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_API_TOKEN=your-api-token
|
||||
```
|
||||
|
||||
5. Enter your **Cloudflare API token**.
|
||||
|
||||
```txt
|
||||
┌ Gateway API token
|
||||
│
|
||||
│
|
||||
└ enter
|
||||
```
|
||||
|
||||
6. Run the `/models` command to select a model.
|
||||
5. Run the `/models` command to select a model.
|
||||
|
||||
```txt
|
||||
/models
|
||||
@@ -547,38 +542,27 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can set environment variables instead of using `/connect`.
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
|
||||
export CLOUDFLARE_API_TOKEN=your-api-token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Cloudflare Workers AI
|
||||
|
||||
Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
|
||||
|
||||
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your **Account ID** and create an API token.
|
||||
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your Account ID and create an API token.
|
||||
|
||||
2. Run the `/connect` command and search for **Cloudflare Workers AI**.
|
||||
2. Set your Account ID as an environment variable.
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||
```
|
||||
|
||||
3. Run the `/connect` command and search for **Cloudflare Workers AI**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Enter your **Account ID** when prompted.
|
||||
|
||||
```txt
|
||||
┌ Enter your Cloudflare Account ID
|
||||
│
|
||||
│
|
||||
└ enter
|
||||
```
|
||||
|
||||
4. Enter your **Cloudflare API key**.
|
||||
4. Enter your Cloudflare API token.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
@@ -587,19 +571,18 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
|
||||
└ enter
|
||||
```
|
||||
|
||||
Or set it as an environment variable.
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_API_KEY=your-api-token
|
||||
```
|
||||
|
||||
5. Run the `/models` command to select a model.
|
||||
|
||||
```txt
|
||||
/models
|
||||
```
|
||||
|
||||
Alternatively, you can set environment variables instead of using `/connect`.
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||
export CLOUDFLARE_API_KEY=your-api-token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Cortecs
|
||||
|
||||
@@ -368,8 +368,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
},
|
||||
"diff_style": "auto",
|
||||
"mouse": true
|
||||
"diff_style": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -382,7 +381,6 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
|
||||
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
|
||||
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
|
||||
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
|
||||
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
|
||||
|
||||
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ You can also access our models through the following API endpoints.
|
||||
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo V2 Pro Free | mimo-v2-pro-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo V2 Omni Free | mimo-v2-omni-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus Free | qwen3.6-plus-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -120,6 +122,8 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
| --------------------------------- | ------ | ------- | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| MiMo V2 Pro Free | Free | Free | Free | - |
|
||||
| MiMo V2 Omni Free | Free | Free | Free | - |
|
||||
| Qwen3.6 Plus Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Super Free | Free | Free | Free | - |
|
||||
| MiniMax M2.5 Free | Free | Free | Free | - |
|
||||
@@ -165,6 +169,8 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don
|
||||
The free models:
|
||||
|
||||
- MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- MiMo V2 Pro Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- MiMo V2 Omni Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Qwen3.6 Plus Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
@@ -212,6 +218,8 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
|
||||
|
||||
- Big Pickle: During its free period, collected data may be used to improve the model.
|
||||
- MiniMax M2.5 Free: During its free period, collected data may be used to improve the model.
|
||||
- MiMo V2 Pro Free: During its free period, collected data may be used to improve the model.
|
||||
- MiMo V2 Omni Free: During its free period, collected data may be used to improve the model.
|
||||
- Qwen3.6 Plus Free: During its free period, collected data may be used to improve the model.
|
||||
- Nemotron 3 Super Free: During its free period, collected data may be used to improve the model.
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.3.17",
|
||||
"version": "1.3.15",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user