Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7b20f8ce5 | |||
| a21e74773f | |||
| 925632089f | |||
| fc95a84b42 | |||
| b0063709e6 | |||
| 77522697b0 | |||
| bbc88eb8e3 | |||
| a379c7956b | |||
| 52eae83ae4 | |||
| 3cc626b5da | |||
| f0849a697c | |||
| 3f3f120825 | |||
| ea17d9bed8 | |||
| 726b2d7e6b | |||
| bffc5938fa | |||
| d111812389 | |||
| 597f47b486 | |||
| 09757c605a | |||
| 0c4f508c50 | |||
| 40db33c415 | |||
| 9b01f15f1d | |||
| 81851ca6b9 | |||
| 3c5632e110 | |||
| 6ea3e6698a | |||
| ed75ce9ecc | |||
| af14fefc96 | |||
| 4a710e4679 | |||
| d2c866bf70 | |||
| 237595e242 | |||
| d29f5eba92 | |||
| fbf889db83 | |||
| ef2357915e | |||
| 5ad6157a08 | |||
| 23b1ae081b | |||
| 8a700d55fc | |||
| c99ccb7e90 |
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewLineCommentRegression"
|
||||
const sessionID = "ses_review_line_comment_regression"
|
||||
const title = "Review line comment regression"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openReview(page)
|
||||
})
|
||||
|
||||
test("opens the comment editor when code is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const value = 'after'", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
await line.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
await lineNumber.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const start = review.locator('[data-column-number="1"]').last()
|
||||
const end = review.locator('[data-column-number="3"]').last()
|
||||
await expectAppVisible(start)
|
||||
await expectAppVisible(end)
|
||||
|
||||
const from = await start.boundingBox()
|
||||
const to = await end.boundingBox()
|
||||
if (!from || !to) throw new Error("Missing line number bounds")
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||
await expect(async () => {
|
||||
await page.mouse.move(0, 0)
|
||||
await lineNumber.hover()
|
||||
await expect(comment).toBeVisible({ timeout: 500 })
|
||||
}).toPass()
|
||||
await comment.click()
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
|
||||
})
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await review.getByText("export const value = 'after'", { exact: true }).click()
|
||||
await review.getByRole("textbox").fill("Use the existing value instead")
|
||||
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
|
||||
|
||||
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "Session" }).click()
|
||||
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
||||
await expect(context).toBeVisible()
|
||||
await expect(context.locator("..")).toContainText("review.ts:2")
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
async function openReview(page: Page) {
|
||||
await page.setViewportSize({ width: 700, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_line_comment_regression",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-line-comment-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-line-comment-regression",
|
||||
projectID: "proj_review_line_comment_regression",
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/review.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_review_line_comment_regression",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_review_line_comment_regression",
|
||||
sessionID,
|
||||
messageID: "msg_review_line_comment_regression",
|
||||
type: "text",
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
|
||||
await page.getByRole("tab", { name: "Changes" }).click()
|
||||
expect(await (await diffResponse).json()).toHaveLength(1)
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
await review
|
||||
.getByRole("heading", { name: /review\.ts/ })
|
||||
.getByRole("button")
|
||||
.first()
|
||||
.click()
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const words = [
|
||||
"vector",
|
||||
]
|
||||
|
||||
const serverKey = "http://127.0.0.1:4096"
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
@@ -139,7 +140,7 @@ function toolPart(
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
...(index % 13 === 0
|
||||
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
serverKey,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
@@ -295,6 +295,7 @@ export const fixture = {
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
|
||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
|
||||
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(shellSubtitle).toHaveText("bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -706,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
||||
}
|
||||
|
||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
||||
console.log(process.env)
|
||||
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface MockServerConfig {
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
vcsDiff?: unknown[]
|
||||
messageDelay?: number
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
events?: () => unknown[]
|
||||
@@ -52,6 +53,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
+11
-5
@@ -1,22 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||
/>
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||
<link rel="shortcut icon" href="/favicon-v3.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#fafafa" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-dvh p-px"></div>
|
||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||
<script src="/src/entry.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
|
||||
|
||||
// Update theme-color meta tag to match app color scheme
|
||||
var metas = document.querySelectorAll("meta[name='theme-color']")
|
||||
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
|
||||
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa")
|
||||
|
||||
if (themeId === "oc-2") return
|
||||
|
||||
|
||||
+198
-63
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
@@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
@@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
|
||||
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
@@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||
}
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
@@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
const TargetSessionRoute = Object.assign(
|
||||
() => {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
function SelectedServerLayout(props: ParentProps) {
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
return (
|
||||
<ServerKey>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
</ServerSyncProvider>
|
||||
<ServerSyncProvider>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</ServerKey>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerLayout(props: ParentProps) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
||||
// server-scoped shell for that server — without ServerKey, so the page never depends
|
||||
// on the globally "selected" server.
|
||||
function DraftServerLayout(props: ParentProps) {
|
||||
function TargetServerLayout(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ serverKey?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const conn = createMemo(() => {
|
||||
if (params.serverKey) {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||
}
|
||||
const id = search.draftId
|
||||
if (!id) return undefined
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
||||
@@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) {
|
||||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
<TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetDirectoryLayout(props: ParentProps) {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverKey = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
|
||||
})
|
||||
|
||||
const resolved = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, "session-route", params.id] as const,
|
||||
enabled: !!params.serverKey && !!params.id,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
|
||||
const root = await rootSession(session, (sessionID) =>
|
||||
serverSDK()
|
||||
.client.session.get({ sessionID })
|
||||
.then((result) => result.data!),
|
||||
)
|
||||
return { session, rootID: root.id }
|
||||
},
|
||||
}))
|
||||
const resolvedDirectory = createMemo(() => {
|
||||
if (params.serverKey) return resolved.data?.session.directory
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
|
||||
})
|
||||
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
|
||||
const home = () => !params.serverKey && !search.draftId
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const current = resolved.data
|
||||
const key = serverKey()
|
||||
if (!current || !key) return
|
||||
tabs.addSessionTab({
|
||||
server: key,
|
||||
sessionId: current.rootID,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
|
||||
<Show when={!home()} fallback={props.children}>
|
||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={!params.serverKey || settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</NewServerScopedShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
||||
<ResolvedDraftRoute />
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||
const tabs = useTabs()
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||
)
|
||||
|
||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||
// directory-scoped providers while keeping the same draft id. The draft's target
|
||||
// server is provided by DraftServerLayout, so changing only the server updates the
|
||||
// SDK/sync hooks without remounting the composer.
|
||||
const directory = () => draft()?.directory
|
||||
|
||||
function ResolvedDraftRoute() {
|
||||
return (
|
||||
<Show when={directory()} keyed>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir}>
|
||||
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -210,32 +293,51 @@ function BodyDesignClass() {
|
||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||
function SharedProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<>
|
||||
<BodyDesignClass />
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</SettingsProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
||||
// each per-route server layout so they resolve to that route's server (selected vs
|
||||
// draft). The Layout remounts when crossing between those groups.
|
||||
function ServerScopedShell(props: ParentProps) {
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider>
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</ModelsProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function NewServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
@@ -439,28 +541,61 @@ export function AppInterface(props: {
|
||||
servers={props.servers}
|
||||
>
|
||||
<GlobalProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Route component={SelectedServerLayout}>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={DraftServerLayout}>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ConnectionGate>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes />
|
||||
</Dynamic>
|
||||
</Show>
|
||||
</ConnectionGate>
|
||||
</SettingsProvider>
|
||||
</GlobalProvider>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Routes() {
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Route component={LegacyServerLayout}>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={TargetServerLayout}>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route
|
||||
path="/session/:id"
|
||||
component={() => {
|
||||
const server = useServer()
|
||||
const { id } = useParams()
|
||||
|
||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Route>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID)
|
||||
tabs.promoteDraft(draftID, {
|
||||
server: server.key,
|
||||
dirBase64: base64Encode(sessionDirectory),
|
||||
sessionId: session.id,
|
||||
})
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -158,6 +159,7 @@ export function SessionHeader() {
|
||||
const isV2 = settings.general.newLayoutDesigns
|
||||
const search = settings.visibility.search
|
||||
const status = settings.visibility.status
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -236,6 +238,7 @@ export function SessionHeader() {
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: command.keybind("review.toggle"),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
@@ -518,6 +521,7 @@ type SessionHeaderV2ActionsState = {
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string
|
||||
reviewVisible: boolean
|
||||
reviewOpened: boolean
|
||||
onReviewToggle: () => void
|
||||
}
|
||||
@@ -530,20 +534,22 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
state={props.state.reviewOpened ? "pressed" : undefined}
|
||||
onClick={props.state.onReviewToggle}
|
||||
aria-label={props.state.reviewLabel}
|
||||
aria-expanded={props.state.reviewOpened}
|
||||
aria-controls="review-panel"
|
||||
icon={<IconV2 name="sidebar-right" />}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
state={props.state.reviewOpened ? "pressed" : undefined}
|
||||
onClick={props.state.onReviewToggle}
|
||||
aria-label={props.state.reviewLabel}
|
||||
aria-expanded={props.state.reviewOpened}
|
||||
aria-controls="review-panel"
|
||||
icon={<IconV2 name="sidebar-right" />}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
@@ -89,6 +90,7 @@ export const SettingsGeneralV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
@@ -345,6 +347,20 @@ export const SettingsGeneralV2: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={mobile()}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
|
||||
>
|
||||
<div data-action="settings-mobile-titlebar-bottom">
|
||||
<Switch
|
||||
checked={settings.general.mobileTitlebarPosition() === "bottom"}
|
||||
onChange={(checked) => settings.general.setMobileTitlebarPosition(checked ? "bottom" : "top")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</Show>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
@@ -14,7 +13,7 @@ import { ServerConnection, serverName } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { DialogServerV2 } from "./dialog-server-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const SettingsServersV2: Component = () => {
|
||||
@@ -55,10 +54,7 @@ export const SettingsServersV2: Component = () => {
|
||||
>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</ButtonV2>
|
||||
<WslAddServerButton />
|
||||
<AddServerMenu onAddServer={openAdd} />
|
||||
</div>
|
||||
<Show when={showSearch()}>
|
||||
<div class="settings-v2-tab-search">
|
||||
|
||||
@@ -29,19 +29,20 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
||||
import { tabHref, useTabs } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { Session } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -87,6 +88,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -98,6 +101,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (useV2Titlebar() && mobile()) {
|
||||
const inset = bottom() ? "env(safe-area-inset-bottom, 0px)" : "env(safe-area-inset-top, 0px)"
|
||||
return `calc(${height}px + ${inset})`
|
||||
}
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
@@ -235,10 +242,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
"shrink-0 relative flex flex-row": true,
|
||||
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
||||
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
|
||||
"order-last": bottom(),
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
||||
"padding-top": useV2Titlebar() && mobile() && !bottom() ? "env(safe-area-inset-top, 0px)" : undefined,
|
||||
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : undefined,
|
||||
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": electronWindows()
|
||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||
@@ -252,7 +262,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<Switch>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
|
||||
@@ -268,6 +278,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
const tabsStoreActions = tabs
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
return route.type === "session" ? route : undefined
|
||||
},
|
||||
(route) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
if (route.type === "home") return
|
||||
@@ -280,10 +301,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
)
|
||||
if (main) return main
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (session?.parentID) {
|
||||
const parentID = session.parentID
|
||||
const s = session()
|
||||
if (s?.parentID) {
|
||||
const parentID = s.parentID
|
||||
const parent = tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||
)
|
||||
@@ -304,15 +324,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}
|
||||
|
||||
if (route.type === "session") {
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (!session) return
|
||||
const sessionId = session.parentID ?? session.id
|
||||
const next = {
|
||||
server: route.server ?? server.key,
|
||||
dirBase64: route.dirBase64,
|
||||
sessionId,
|
||||
}
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const sessionId = s.parentID ?? s.id
|
||||
const next = { server: route.server ?? server.key, sessionId }
|
||||
tabsStoreActions.addSessionTab(next)
|
||||
}
|
||||
})
|
||||
@@ -421,10 +436,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||
classList={{
|
||||
"pl-2": mac(),
|
||||
"pl-4": !mac(),
|
||||
"pt-2": !bottom(),
|
||||
"pb-2": bottom(),
|
||||
"md:pl-2": mac(),
|
||||
"md:pl-4": !mac(),
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator />
|
||||
@@ -495,25 +512,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
)
|
||||
}
|
||||
|
||||
const [session] = createResource(
|
||||
() => tab.sessionId,
|
||||
(sessionID) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
<Show when={session()}>
|
||||
{(session) => (
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
sessionId={tab.sessionId}
|
||||
session={session()}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
@@ -793,7 +823,6 @@ function TabNavItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
directory: string
|
||||
sessionId?: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
@@ -801,31 +830,19 @@ function TabNavItem(props: {
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
session: Session
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
})
|
||||
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
|
||||
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const ctx = dirSyncCtx()
|
||||
if (!ctx || !props.sessionId) return
|
||||
return [props.sessionId, ctx] as const
|
||||
},
|
||||
async ([sessionId, dirSyncCtx]) => {
|
||||
await dirSyncCtx.session.sync(sessionId).catch(() => {})
|
||||
return dirSyncCtx.session.get(sessionId)
|
||||
},
|
||||
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -837,7 +854,7 @@ function TabNavItem(props: {
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={session.latest}>
|
||||
<Show when={props.session}>
|
||||
{(session) => {
|
||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||
|
||||
@@ -853,7 +870,7 @@ function TabNavItem(props: {
|
||||
<span data-slot="project-avatar-slot">
|
||||
<ProjectTabAvatar
|
||||
project={project()}
|
||||
directory={props.directory}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
|
||||
@@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
@@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const cache = createScopedCache(
|
||||
(key) => {
|
||||
@@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
return cache.get(key).value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
@@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const scope = createMemo(() => sdk().directory)
|
||||
const path = createPathHelpers(scope)
|
||||
const tabs = layout.tabs(() =>
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
|
||||
)
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
|
||||
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
|
||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||
|
||||
@@ -79,7 +80,7 @@ export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
|
||||
|
||||
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||
const all = current?.all ?? []
|
||||
@@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
return { type: "draft", draftID }
|
||||
}
|
||||
|
||||
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
|
||||
return {
|
||||
type: "session",
|
||||
sessionId: parts[3],
|
||||
server: requireServerKey(parts[1]),
|
||||
}
|
||||
}
|
||||
|
||||
const dirBase64 = parts[0]
|
||||
const dir = decode64(dirBase64)
|
||||
if (!dir) return { type: "home" }
|
||||
@@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
if (parts[1] !== "session") return { type: "home" }
|
||||
|
||||
const id = parts[2]
|
||||
if (id) return { type: "session", dir, dirBase64, sessionId: id }
|
||||
if (id) return { type: "session", sessionId: id }
|
||||
return { type: "dir-new-sesssion", dir, dirBase64 }
|
||||
}
|
||||
|
||||
@@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const route = createMemo(() => {
|
||||
const value = currentRoute(location.pathname, location.search)
|
||||
if (value.type === "home") return value
|
||||
if (value.server) return value
|
||||
return { ...value, server: server.key }
|
||||
})
|
||||
|
||||
@@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
handoff: {
|
||||
tabs: createMemo(() => store.handoff?.tabs),
|
||||
setTabs(dir: string, id: string) {
|
||||
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
|
||||
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
|
||||
},
|
||||
clearTabs() {
|
||||
if (!store.handoff?.tabs) return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
@@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
gate: false,
|
||||
init: () => {
|
||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
@@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
return decode64(params.dir)
|
||||
return props.directory?.() ?? decode64(params.dir)
|
||||
})
|
||||
|
||||
const currentSession = createMemo(() => params.id)
|
||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
gate: false,
|
||||
init: () => {
|
||||
init: (props: { directory?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return false
|
||||
const [store] = serverSync().child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
@@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
const directory = props.directory?.() ?? decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
@@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
@@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() =>
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
|
||||
)
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showSessionProgressBar: boolean
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
appearance: {
|
||||
@@ -118,6 +119,7 @@ const defaultSettings: Settings = {
|
||||
editToolPartsExpanded: false,
|
||||
showSessionProgressBar: true,
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -248,6 +250,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
mobileTitlebarPosition: withFallback(
|
||||
() => store.general?.mobileTitlebarPosition,
|
||||
defaultSettings.general.mobileTitlebarPosition,
|
||||
),
|
||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||
setStore("general", "mobileTitlebarPosition", value)
|
||||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
@@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { usePlatform } from "./platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
server: ServerConnection.Key
|
||||
dirBase64: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
@@ -34,16 +33,12 @@ type RecentTab = {
|
||||
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||
|
||||
export const tabHref = (tab: Tab) =>
|
||||
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
|
||||
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
|
||||
|
||||
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||
|
||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
||||
const dirBase64 = base64Encode(session.directory)
|
||||
return tabs.some(
|
||||
(tab) =>
|
||||
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
|
||||
)
|
||||
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
||||
}
|
||||
|
||||
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
@@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
setRecentKey(tabKey(tab))
|
||||
if (tab.server === server.key) {
|
||||
navigate(href)
|
||||
return
|
||||
}
|
||||
void startTransition(() => {
|
||||
server.setActive(tab.server)
|
||||
navigate(href)
|
||||
})
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const actions = {
|
||||
@@ -195,11 +183,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) =>
|
||||
tab.type === "session" &&
|
||||
tab.server === server.key &&
|
||||
atob(tab.dirBase64) === input.directory &&
|
||||
input.sessionIDs.includes(tab.sessionId),
|
||||
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
|
||||
)
|
||||
.map(tabKey)
|
||||
void startTransition(() => {
|
||||
@@ -211,7 +195,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
? tabHref({
|
||||
type: "session",
|
||||
server: server.key,
|
||||
dirBase64: params.dir,
|
||||
sessionId: params.id,
|
||||
})
|
||||
: undefined
|
||||
@@ -224,14 +207,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const removedCurrent =
|
||||
currentTab?.type === "session" &&
|
||||
currentTab.server === server.key &&
|
||||
atob(currentTab.dirBase64) === input.directory &&
|
||||
sessionIDs.has(currentTab.sessionId)
|
||||
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
const tab = tabs[i]
|
||||
if (!tab || tab.type !== "session") continue
|
||||
if (tab.server !== server.key) continue
|
||||
if (atob(tab.dirBase64) !== input.directory) continue
|
||||
if (!sessionIDs.has(tab.sessionId)) continue
|
||||
tabs.splice(i, 1)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK, type DirectorySDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
|
||||
@@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const serverSDK = useServerSDK()
|
||||
const params = useParams()
|
||||
const cache = new Map<string, TerminalCacheEntry>()
|
||||
const scope = server.scope()
|
||||
const scope = () => serverSDK().scope
|
||||
const directory = createMemo(() => base64Encode(sdk().directory))
|
||||
|
||||
caches.add(cache)
|
||||
onCleanup(() => caches.delete(cache))
|
||||
@@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
|
||||
const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id, scope }),
|
||||
() => ({ dir: directory(), id: params.id, scope: scope() }),
|
||||
(next, prev) => {
|
||||
if (!prev?.dir) return
|
||||
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
|
||||
|
||||
@@ -589,6 +589,8 @@ export const dict = {
|
||||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
"home.sessions.search.sessions": "Sessions",
|
||||
"home.sessions.search.noResults": "No sessions found for {{query}}",
|
||||
@@ -839,6 +841,9 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
|
||||
@@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) {
|
||||
export function DirectoryDataProvider(
|
||||
props: ParentProps<{
|
||||
directory: string | Accessor<string>
|
||||
draftID?: string
|
||||
server?: Accessor<ServerConnection.Key | undefined>
|
||||
}>,
|
||||
) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
const server = props.server?.()
|
||||
if (server) return sessionHref(server, sessionID)
|
||||
return `/${slug()}/session/${sessionID}`
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID) return
|
||||
if (props.draftID || props.server?.()) return
|
||||
const next = sync().data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
if (!next || next === directory()) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
@@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={props.directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
||||
directory={directory()}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
|
||||
@@ -43,10 +43,10 @@ import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_ROW_LAYOUT =
|
||||
@@ -113,16 +113,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
||||
<HomeDesign />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeDesign() {
|
||||
export function NewHome() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
@@ -313,7 +304,7 @@ function HomeDesign() {
|
||||
const ctx = global.createServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
@@ -339,7 +330,7 @@ function HomeDesign() {
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
selected={state.selection}
|
||||
@@ -365,7 +356,7 @@ function HomeDesign() {
|
||||
/>
|
||||
|
||||
<section
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12"
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
@@ -416,7 +407,7 @@ function HomeDesign() {
|
||||
record={record}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
openSession={openSession}
|
||||
onClick={() => openSession(record.session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -429,6 +420,12 @@ function HomeDesign() {
|
||||
</div>
|
||||
</ScrollView>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -452,8 +449,15 @@ function HomeProjectColumn(props: {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||
const [state, setState] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
return (
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
|
||||
<aside
|
||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
@@ -477,20 +481,23 @@ function HomeProjectColumn(props: {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.createServerCtx(item)
|
||||
const collapsed = () => !!state.collapsed[key]
|
||||
return (
|
||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
selected={props.selected.server === key && !props.selected.directory}
|
||||
healthy={healthy()}
|
||||
collapsed={collapsed()}
|
||||
health={global.servers.health[key]}
|
||||
controller={controller}
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy()}>
|
||||
<Show when={healthy() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
|
||||
</Show>
|
||||
@@ -499,37 +506,55 @@ function HomeProjectColumn(props: {
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="mt-4 flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openSettings}
|
||||
>
|
||||
<IconV2 name="settings-gear" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openHelp}
|
||||
>
|
||||
<IconV2 name="help" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<HomeUtilityNav
|
||||
class="mt-4 hidden lg:flex"
|
||||
openSettings={props.openSettings}
|
||||
openHelp={props.openHelp}
|
||||
language={props.language}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeUtilityNav(props: {
|
||||
class?: string
|
||||
openSettings: () => void
|
||||
openHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openSettings}
|
||||
>
|
||||
<IconV2 name="settings-gear" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openHelp}
|
||||
>
|
||||
<IconV2 name="help" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeServerRow(props: {
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
healthy: boolean
|
||||
collapsed: boolean
|
||||
health: ServerHealth | undefined
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
focusServer: (server: ServerConnection.Any) => void
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
openEdit: (server: ServerConnection.Http) => void
|
||||
toggleCollapsed: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
@@ -542,7 +567,30 @@ function HomeServerRow(props: {
|
||||
disabled={!props.healthy}
|
||||
onClick={() => props.focusServer(props.server)}
|
||||
>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Show when={props.healthy}>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-expanded={!props.collapsed}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.toggleCollapsed()
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
@@ -1024,7 +1072,7 @@ function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
openSession: (session: Session) => void
|
||||
onClick: () => void
|
||||
}) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
|
||||
@@ -1033,7 +1081,7 @@ function HomeSessionRow(props: {
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
@@ -1093,7 +1141,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||
].filter((group) => group.sessions.length > 0)
|
||||
}
|
||||
|
||||
function LegacyHome() {
|
||||
export function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createEffect, type ParentProps } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
},
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
install: () => void platform.updater?.install(),
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar update={update} />
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
{props.children}
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+149
-170
@@ -13,7 +13,7 @@ import {
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
export default function LegacyLayout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
||||
@@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
|
||||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
createEffect(() => setV2Toast(newDesign()))
|
||||
createEffect(() => setV2Toast(false))
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
const slug = params.dir
|
||||
if (!slug) return { slug, dir: "" }
|
||||
@@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
autoselect: !initialDirectory,
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
@@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
|
||||
id: "sidebar.toggle",
|
||||
title: language.t("command.sidebar.toggle"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: newDesign() ? undefined : "mod+b",
|
||||
keybind: "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
{
|
||||
@@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
if (!newDesign())
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
commands.push({
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
})
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
commands.push({
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
})
|
||||
})
|
||||
|
||||
for (const [id] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
@@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
`${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!newDesign()}
|
||||
fallback={
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
</Show>
|
||||
<HelpButton />
|
||||
<ToastRegion v2={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
@@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useServer } from "@/context/server"
|
||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
@@ -92,7 +91,6 @@ export default function Page() {
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const terminal = useTerminal()
|
||||
const server = useServer()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
||||
@@ -137,11 +135,11 @@ export default function Page() {
|
||||
layout.handoff.clearTabs()
|
||||
return
|
||||
}
|
||||
if (pending.scope !== server.scope()) return
|
||||
if (pending.scope !== serverSDK().scope) return
|
||||
|
||||
if (pending.id !== id) return
|
||||
layout.handoff.clearTabs()
|
||||
if (pending.dir !== (params.dir ?? "")) return
|
||||
if (pending.dir !== base64Encode(sdk().directory)) return
|
||||
|
||||
const from = workspaceTabs().tabs()
|
||||
if (from.all.length === 0 && !from.active) return
|
||||
@@ -247,7 +245,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
() => ({ dir: sdk().directory, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
@@ -575,7 +573,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
() => sdk().directory,
|
||||
(dir) => {
|
||||
if (!dir) return
|
||||
setStore("newSessionWorktree", "main")
|
||||
@@ -1570,40 +1568,56 @@ export default function Page() {
|
||||
/>
|
||||
)
|
||||
|
||||
const mobileTabs = (compact = false, bottom = false) => (
|
||||
<Tabs value={store.mobileTab} class="h-auto">
|
||||
<Tabs.List
|
||||
classList={{
|
||||
"!h-9": compact,
|
||||
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": bottom,
|
||||
}}
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value="session"
|
||||
classList={{
|
||||
"!w-1/2 !max-w-none": true,
|
||||
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
|
||||
}}
|
||||
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "session")}
|
||||
>
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="changes"
|
||||
classList={{
|
||||
"!w-1/2 !max-w-none !border-r-0": true,
|
||||
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
|
||||
}}
|
||||
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "changes")}
|
||||
>
|
||||
{hasReview()
|
||||
? language.t("session.review.filesChanged", { count: reviewCount() })
|
||||
: language.t("session.review.change.other")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)
|
||||
const mobileTabsBottom = createMemo(
|
||||
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{sessionSync() ?? ""}
|
||||
<SessionHeader />
|
||||
<div
|
||||
class="flex-1 min-h-0 flex flex-col md:flex-row "
|
||||
class="flex-1 min-h-0 flex flex-col md:flex-row"
|
||||
classList={{
|
||||
"gap-2 p-2": settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<Show when={!isDesktop() && !!params.id}>
|
||||
<Tabs value={store.mobileTab} class="h-auto">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger
|
||||
value="session"
|
||||
class="!w-1/2 !max-w-none"
|
||||
classes={{ button: "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "session")}
|
||||
>
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="changes"
|
||||
class="!w-1/2 !max-w-none !border-r-0"
|
||||
classes={{ button: "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "changes")}
|
||||
>
|
||||
{hasReview()
|
||||
? language.t("session.review.filesChanged", { count: reviewCount() })
|
||||
: language.t("session.review.change.other")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</Show>
|
||||
<Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
@@ -1622,6 +1636,9 @@ export default function Page() {
|
||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
>
|
||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||
{mobileTabs(true)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
@@ -1629,8 +1646,8 @@ export default function Page() {
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
@@ -1686,7 +1703,8 @@ export default function Page() {
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</div>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -200,7 +201,11 @@ export function SessionComposerRegion(props: {
|
||||
const openParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(`/${route.params.dir}/session/${id}`)
|
||||
navigate(
|
||||
route.params.serverKey
|
||||
? sessionHref(requireServerKey(route.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -405,7 +405,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
enableHoverUtility
|
||||
enableGutterUtility
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
@@ -413,11 +413,10 @@ export function FileTabContent(props: { tab: string }) {
|
||||
}}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
renderHoverUtility={commentsUi.renderHoverUtility}
|
||||
renderGutterUtility={commentsUi.renderGutterUtility}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
commentsUi.onLineSelected(range)
|
||||
}}
|
||||
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
|
||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
commentsUi.onLineSelectionEnd(range)
|
||||
}}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useServer } from "@/context/server"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
export const useSessionKey = () => {
|
||||
const params = useParams()
|
||||
const server = useServer()
|
||||
const scope = createMemo(() => server.scope())
|
||||
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
|
||||
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const scope = createMemo(() => serverSDK().scope)
|
||||
const directory = createMemo(() => base64Encode(sdk().directory))
|
||||
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
|
||||
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
|
||||
return { params, sessionKey, workspaceKey }
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
@@ -25,10 +26,11 @@ export function TerminalPanel() {
|
||||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const { params, workspaceKey, view } = useSessionLayout()
|
||||
const { workspaceKey, view } = useSessionLayout()
|
||||
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const size = createSizing()
|
||||
@@ -122,7 +124,7 @@ export function TerminalPanel() {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const dir = params.dir
|
||||
const dir = sdk().directory
|
||||
if (!dir) return
|
||||
if (!terminal.ready()) return
|
||||
language.locale()
|
||||
@@ -140,7 +142,7 @@ export function TerminalPanel() {
|
||||
})
|
||||
|
||||
const handoff = createMemo(() => {
|
||||
const dir = params.dir
|
||||
const dir = sdk().directory
|
||||
if (!dir) return []
|
||||
return getTerminalHandoff(workspaceKey()) ?? []
|
||||
})
|
||||
|
||||
@@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
@@ -71,6 +73,7 @@ import { makeTimer } from "@solid-primitives/timer"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
@@ -260,6 +263,7 @@ export function MessageTimeline(props: {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
@@ -449,7 +453,10 @@ export function MessageTimeline(props: {
|
||||
const id = activeMessageID()
|
||||
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
|
||||
return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
@@ -757,12 +764,18 @@ export function MessageTimeline(props: {
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
const href = (id: string) =>
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
navigate(href(parentID))
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
navigate(href(nextSessionID))
|
||||
return
|
||||
}
|
||||
if (params.serverKey) {
|
||||
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
@@ -864,7 +877,9 @@ export function MessageTimeline(props: {
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
navigate(
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
@@ -1285,7 +1300,9 @@ export function MessageTimeline(props: {
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"pr-3": true,
|
||||
"pl-4": settings.general.newLayoutDesigns(),
|
||||
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function filterVirtualIndexes(indexes: number[], count: number) {
|
||||
return indexes.filter((index) => index >= 0 && index < count)
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
@@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const sessionTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
@@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
onSelect: () => {
|
||||
if (params.serverKey) {
|
||||
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
|
||||
|
||||
describe("session routes", () => {
|
||||
test("builds and decodes a server-keyed session route", () => {
|
||||
const server = ServerConnection.Key.make("https://example.com:4096")
|
||||
const href = sessionHref(server, "session-1")
|
||||
|
||||
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
|
||||
expect(requireServerKey(href.split("/")[2])).toBe(server)
|
||||
})
|
||||
|
||||
test("rejects malformed server keys", () => {
|
||||
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
|
||||
})
|
||||
|
||||
test("builds the legacy directory-keyed route", () => {
|
||||
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
|
||||
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
|
||||
)
|
||||
})
|
||||
|
||||
test("resolves the root session", async () => {
|
||||
const sessions: Record<string, { id: string; parentID?: string }> = {
|
||||
child: { id: "child", parentID: "parent" },
|
||||
parent: { id: "parent", parentID: "root" },
|
||||
root: { id: "root" },
|
||||
}
|
||||
|
||||
expect(
|
||||
await rootSession(sessions.child, async (id) => {
|
||||
const session = sessions[id]
|
||||
if (!session) throw new Error(`Missing session: ${id}`)
|
||||
return session
|
||||
}),
|
||||
).toBe(sessions.root)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
export function legacySessionHref(directory: string, sessionID: string) {
|
||||
return `/${base64Encode(directory)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
export function requireServerKey(segment: string | undefined) {
|
||||
const key = decode64(segment)
|
||||
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
|
||||
return ServerConnection.Key.make(key)
|
||||
}
|
||||
|
||||
type SessionParent = { id: string; parentID?: string }
|
||||
|
||||
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
|
||||
let current = session
|
||||
while (current.parentID) current = await get(current.parentID)
|
||||
return current
|
||||
}
|
||||
@@ -24,11 +24,11 @@ export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
}
|
||||
|
||||
export function WslAddServerButton() {
|
||||
export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAdd = () => {
|
||||
const openAddWsl = () => {
|
||||
dialog.push(() => (
|
||||
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogAddWslServer />
|
||||
@@ -36,10 +36,25 @@ export function WslAddServerButton() {
|
||||
))
|
||||
}
|
||||
return (
|
||||
<Show when={platform.wslServers}>
|
||||
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
|
||||
{language.t("wsl.server.addShort")}
|
||||
</ButtonV2>
|
||||
<Show
|
||||
when={platform.wslServers}
|
||||
fallback={
|
||||
<ButtonV2 variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</ButtonV2>
|
||||
}
|
||||
>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end">
|
||||
<MenuV2.Trigger as={ButtonV2} variant="ghost-muted" icon="plus">
|
||||
{language.t("dialog.server.add.button")}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createVirtualizer } from "@tanstack/solid-virtual"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
|
||||
|
||||
test("reactive count updates preserve measured row sizes", () => {
|
||||
createRoot((dispose) => {
|
||||
@@ -44,3 +45,26 @@ test("logical scroll offset includes pending measurement adjustments", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, setCount] = createSignal(2)
|
||||
const pinned = [1]
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return count()
|
||||
},
|
||||
getScrollElement: () => null,
|
||||
estimateSize: () => 60,
|
||||
initialRect: { width: 800, height: 600 },
|
||||
rangeExtractor: (range) =>
|
||||
filterVirtualIndexes([...new Set([...defaultRangeExtractor(range), ...pinned])], range.count),
|
||||
})
|
||||
|
||||
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([0, 1])
|
||||
setCount(1)
|
||||
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([0])
|
||||
expect(() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item]))).not.toThrow()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function handler(
|
||||
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
||||
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
const userAgent = input.request.headers.get("user-agent") ?? ""
|
||||
logger.metric({
|
||||
is_stream: isStream,
|
||||
@@ -193,14 +194,12 @@ export async function handler(
|
||||
headers: (() => {
|
||||
const headers = new Headers(input.request.headers)
|
||||
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
|
||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
|
||||
if (v === "$ip") return headers.set(k, ip)
|
||||
if (v === "$session") return headers.set(k, sessionId)
|
||||
if (v === "$model") return headers.set(k, model)
|
||||
if (v === "$request") return headers.set(k, requestId)
|
||||
if (v === "$project") return headers.set(k, projectId)
|
||||
headers.set(k, v)
|
||||
})
|
||||
headers.delete("host")
|
||||
|
||||
@@ -52,10 +52,8 @@ export namespace ZenData {
|
||||
api: z.string(),
|
||||
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
|
||||
format: FormatSchema.optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
headerModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
budget: z.number().optional(),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
@@ -29,6 +29,7 @@ export const Event = {
|
||||
export interface Interface {
|
||||
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly wait: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
|
||||
@@ -41,13 +42,18 @@ export const layer = Layer.effect(
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<ID, Scope.Closeable>()
|
||||
const loading = new Set<ID>()
|
||||
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
|
||||
const failures = new Map<ID, Exit.Exit<void, never>>()
|
||||
let host: Parameters<Plugin["effect"]>[0]
|
||||
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
Effect.sync(() => loading.add(id)).pipe(
|
||||
Effect.sync(() => {
|
||||
loading.add(id)
|
||||
failures.delete(id)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
@@ -61,11 +67,22 @@ export const layer = Layer.effect(
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
)
|
||||
active.set(id, child)
|
||||
yield* events.publish(Event.Added, { id })
|
||||
active.set(id, child)
|
||||
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
|
||||
discard: true,
|
||||
})
|
||||
waiters.delete(id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.onExit((exit) => {
|
||||
if (Exit.isSuccess(exit)) return Effect.void
|
||||
failures.set(id, exit)
|
||||
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
|
||||
discard: true,
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => loading.delete(id))),
|
||||
),
|
||||
)
|
||||
@@ -79,12 +96,41 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const current = active.get(id)
|
||||
active.delete(id)
|
||||
failures.delete(id)
|
||||
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
|
||||
const waiter = yield* Deferred.make<void>()
|
||||
const pending = yield* locks.withLock(id)(
|
||||
Effect.sync(() => {
|
||||
if (active.has(id)) return false
|
||||
const failure = failures.get(id)
|
||||
if (failure) return failure
|
||||
const current = waiters.get(id) ?? new Set()
|
||||
current.add(waiter)
|
||||
waiters.set(id, current)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (!pending) return
|
||||
if (typeof pending !== "boolean") return yield* pending
|
||||
yield* Deferred.await(waiter).pipe(
|
||||
Effect.ensuring(
|
||||
locks.withLock(id)(
|
||||
Effect.sync(() => {
|
||||
const current = waiters.get(id)
|
||||
current?.delete(waiter)
|
||||
if (current?.size === 0) waiters.delete(id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
@@ -95,6 +141,7 @@ export const layer = Layer.effect(
|
||||
const service = Service.of({
|
||||
add,
|
||||
remove,
|
||||
wait,
|
||||
})
|
||||
host = yield* PluginHost.make(service)
|
||||
return service
|
||||
|
||||
@@ -98,6 +98,7 @@ export const locationLayer = Layer.effectDiscard(
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
@@ -106,7 +107,6 @@ export const locationLayer = Layer.effectDiscard(
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
@@ -28,9 +28,6 @@ export const Input = Schema.Struct({
|
||||
.annotate({
|
||||
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
|
||||
}),
|
||||
description: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Concise description of the command's purpose",
|
||||
}),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
@@ -9,6 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("PluginV2", () => {
|
||||
it.effect("waits for a plugin and returns immediately once active", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const id = PluginV2.ID.make("waited")
|
||||
const waiting = yield* plugins.wait(id).pipe(Effect.forkChild)
|
||||
|
||||
yield* plugins.add(id, () => Effect.void)
|
||||
yield* Fiber.join(waiting)
|
||||
yield* plugins.wait(id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates plugin activation defects to waiters", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const id = PluginV2.ID.make("failed")
|
||||
const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild)
|
||||
|
||||
const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit)
|
||||
const pending = yield* Fiber.join(waiting)
|
||||
const later = yield* plugins.wait(id).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(added)).toBe(true)
|
||||
expect(Exit.isFailure(pending)).toBe(true)
|
||||
expect(Exit.isFailure(later)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds, replaces, and removes plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
import path from "path"
|
||||
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json")
|
||||
process.env.OPENCODE_DISABLE_MODELS_FETCH = "true"
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("AppProcess", () => {
|
||||
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" }))
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" }))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
|
||||
expect(yield* waitForFile(settled)).toBe("settled")
|
||||
|
||||
@@ -134,10 +134,9 @@ describe("BashTool", () => {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
|
||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
|
||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
|
||||
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
|
||||
).toEqual({
|
||||
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
|
||||
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
|
||||
output: {
|
||||
structured: {
|
||||
|
||||
@@ -266,7 +266,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
|
||||
// For shell tools, surface the actual command as the title so it stays visible
|
||||
// before output lands; non-shell tools keep their model-provided title.
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||
if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName
|
||||
if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
@@ -98,9 +99,12 @@ export const layer = Layer.effect(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const referenceDirs = yield* Effect.gen(function* () {
|
||||
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
||||
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
|
||||
? yield* Effect.gen(function* () {
|
||||
yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference"))
|
||||
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
||||
: []
|
||||
const whitelistedDirs = [
|
||||
Truncate.GLOB,
|
||||
path.join(Global.Path.tmp, "*"),
|
||||
|
||||
@@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
|
||||
|
||||
function scrollBashStart(p: ToolProps<typeof BashTool>): string {
|
||||
const cmd = p.input.command ?? ""
|
||||
const desc = p.input.description || "Shell"
|
||||
const wd = p.input.workdir ?? ""
|
||||
const dir = wd && wd !== "." ? toolPath(wd) : ""
|
||||
if (cmd && desc === "Shell" && !dir) {
|
||||
const formatted = wd && wd !== "." ? toolPath(wd) : ""
|
||||
const dir = formatted === "." ? "" : formatted
|
||||
if (cmd && !dir) {
|
||||
return `$ ${cmd}`
|
||||
}
|
||||
|
||||
const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
|
||||
|
||||
if (!cmd) {
|
||||
return `# ${title}`
|
||||
return dir ? `# Running in ${dir}` : ""
|
||||
}
|
||||
|
||||
return `# ${title}\n$ ${cmd}`
|
||||
return `# Running in ${dir}\n$ ${cmd}`
|
||||
}
|
||||
|
||||
function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
|
||||
@@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
}
|
||||
|
||||
function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
|
||||
const title = p.input.description || "Shell command"
|
||||
const cmd = p.input.command || ""
|
||||
return {
|
||||
icon: "#",
|
||||
title,
|
||||
title: "Shell command",
|
||||
lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy
|
||||
|
||||
Heap.start()
|
||||
|
||||
const onUnhandledRejection = (_error: unknown) => {}
|
||||
|
||||
const onUncaughtException = (_error: Error) => {}
|
||||
|
||||
process.on("unhandledRejection", onUnhandledRejection)
|
||||
process.on("uncaughtException", onUncaughtException)
|
||||
|
||||
// Subscribe to global events and forward them via RPC
|
||||
GlobalBus.on("event", (event) => {
|
||||
Rpc.emit("global.event", event)
|
||||
@@ -65,6 +72,8 @@ export const rpc = {
|
||||
async shutdown() {
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
if (server) await server.stop(true)
|
||||
process.off("unhandledRejection", onUnhandledRejection)
|
||||
process.off("uncaughtException", onUncaughtException)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export function fetch<T extends { name: string }>(
|
||||
client: Client,
|
||||
list: (client: Client) => Promise<T[]>,
|
||||
label: string,
|
||||
key?: (item: T) => string,
|
||||
) {
|
||||
return Effect.tryPromise({
|
||||
try: () => list(client),
|
||||
@@ -100,7 +101,10 @@ export function fetch<T extends { name: string }>(
|
||||
Effect.map((items) => {
|
||||
const sanitizedClient = sanitize(clientName)
|
||||
return Object.fromEntries(
|
||||
items.map((item) => [sanitizedClient + ":" + sanitize(item.name), { ...item, client: clientName }]),
|
||||
items.map((item) => [
|
||||
key ? clientName + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name),
|
||||
{ ...item, client: clientName },
|
||||
]),
|
||||
)
|
||||
}),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
|
||||
@@ -161,7 +161,7 @@ export interface Interface {
|
||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
||||
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||
@@ -654,17 +654,22 @@ export const layer = Layer.effect(
|
||||
s: State,
|
||||
listFn: (c: Client, timeout?: number) => Promise<T[]>,
|
||||
label: string,
|
||||
key?: (item: T) => string,
|
||||
targetClientName?: string,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const cfg = yield* cfgSvc.get()
|
||||
return yield* Effect.forEach(
|
||||
Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
|
||||
Object.entries(s.clients).filter(
|
||||
([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName),
|
||||
),
|
||||
([clientName, client]) =>
|
||||
McpCatalog.fetch(
|
||||
clientName,
|
||||
client,
|
||||
(c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)),
|
||||
label,
|
||||
key,
|
||||
).pipe(Effect.map((items) => Object.entries(items ?? {}))),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
|
||||
@@ -675,8 +680,14 @@ export const layer = Layer.effect(
|
||||
return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts")
|
||||
})
|
||||
|
||||
const resources = Effect.fn("MCP.resources")(function* () {
|
||||
return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.resources, "resources")
|
||||
const resources = Effect.fn("MCP.resources")(function* (clientName?: string) {
|
||||
return yield* collectFromConnected(
|
||||
yield* InstanceState.get(state),
|
||||
McpCatalog.resources,
|
||||
"resources",
|
||||
(resource) => resource.uri,
|
||||
clientName,
|
||||
)
|
||||
})
|
||||
|
||||
const withClient = Effect.fnUntraced(function* <A>(
|
||||
|
||||
@@ -214,9 +214,10 @@ export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[]
|
||||
|
||||
export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
|
||||
const edits = ["edit", "write", "apply_patch"]
|
||||
const reads = ["list_mcp_resources", "read_mcp_resource"]
|
||||
return new Set(
|
||||
tools.filter((tool) => {
|
||||
const permission = edits.includes(tool) ? "edit" : tool
|
||||
const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||
return rule?.pattern === "*" && rule.action === "deny"
|
||||
}),
|
||||
|
||||
@@ -66,6 +66,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info)
|
||||
const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part)
|
||||
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
|
||||
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
|
||||
"application/pdf",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
])
|
||||
|
||||
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
|
||||
|
||||
@@ -77,6 +85,18 @@ IMPORTANT:
|
||||
|
||||
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
|
||||
|
||||
function mcpResourceBase64Size(value: string) {
|
||||
const trimmed = value.replace(/\s/g, "")
|
||||
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
|
||||
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
|
||||
}
|
||||
|
||||
function formatMcpResourceBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
|
||||
return `${Math.ceil(value / (1024 * 1024))} MB`
|
||||
}
|
||||
|
||||
function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
|
||||
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
|
||||
// They are not pending work and must not trigger an assistant-prefill request.
|
||||
@@ -542,7 +562,7 @@ export const layer = Layer.effect(
|
||||
time: { ...part.state.time, end: completed },
|
||||
input: part.state.input,
|
||||
title: "",
|
||||
metadata: { output, description: "" },
|
||||
metadata: { output },
|
||||
output,
|
||||
}
|
||||
yield* sessions.updatePart(part)
|
||||
@@ -569,7 +589,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
output += chunk
|
||||
if (part.state.status === "running") {
|
||||
part.state.metadata = { output, description: "" }
|
||||
part.state.metadata = { output }
|
||||
yield* sessions.updatePart(part)
|
||||
}
|
||||
}),
|
||||
@@ -731,7 +751,8 @@ export const layer = Layer.effect(
|
||||
if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
|
||||
const items = Array.isArray(content.contents) ? content.contents : [content.contents]
|
||||
for (const c of items) {
|
||||
if ("text" in c && c.text) {
|
||||
if (!c || typeof c !== "object") continue
|
||||
if ("text" in c && typeof c.text === "string" && c.text) {
|
||||
pieces.push({
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
@@ -739,18 +760,47 @@ export const layer = Layer.effect(
|
||||
synthetic: true,
|
||||
text: c.text,
|
||||
})
|
||||
} else if ("blob" in c && c.blob) {
|
||||
const mime = "mimeType" in c ? c.mimeType : part.mime
|
||||
} else if ("blob" in c && typeof c.blob === "string" && c.blob) {
|
||||
const mime = "mimeType" in c && typeof c.mimeType === "string" ? c.mimeType : part.mime
|
||||
const filename = "uri" in c && typeof c.uri === "string" ? c.uri : part.filename
|
||||
const size = mcpResourceBase64Size(c.blob)
|
||||
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
||||
pieces.push({
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) is not a supported attachment type]`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
||||
pieces.push({
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) exceeds ${formatMcpResourceBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
pieces.push({
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: `[Binary content: ${mime}]`,
|
||||
text: `[Binary MCP resource attached: ${filename ?? uri} (${mime})]`,
|
||||
})
|
||||
pieces.push({
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "file",
|
||||
mime,
|
||||
filename,
|
||||
url: `data:${mime};base64,${c.blob}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
|
||||
} else {
|
||||
const error = Cause.squash(exit.cause)
|
||||
yield* Effect.logError("failed to read MCP resource", { error, clientName, uri })
|
||||
|
||||
@@ -20,6 +20,18 @@ import { PartID } from "./schema"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
|
||||
const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
|
||||
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
|
||||
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
|
||||
"application/pdf",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
])
|
||||
|
||||
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
agent: Agent.Info
|
||||
@@ -114,6 +126,175 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
})
|
||||
}
|
||||
|
||||
const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
|
||||
(client) => !!client.getServerCapabilities()?.resources,
|
||||
)
|
||||
if (hasMcpResourceServer) {
|
||||
tools[LIST_MCP_RESOURCES_TOOL] = tool({
|
||||
description:
|
||||
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
|
||||
inputSchema: jsonSchema(
|
||||
ProviderTransform.schema(input.model, {
|
||||
type: "object",
|
||||
properties: {
|
||||
server: {
|
||||
type: "string",
|
||||
description: "Optional MCP server name. When omitted, lists resources from every connected server.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
),
|
||||
execute(args, opts) {
|
||||
return run.promise(
|
||||
Effect.gen(function* () {
|
||||
const parsed = parseListMcpResourcesArgs(args)
|
||||
const ctx = context(toRecord(args), opts)
|
||||
const clients = yield* mcp.clients()
|
||||
const resourceServers = Object.entries(clients)
|
||||
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
|
||||
.map((entry) => entry[0])
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
if (parsed.server && !resourceServers.includes(parsed.server)) {
|
||||
throw new Error(
|
||||
resourceServers.length === 0
|
||||
? `MCP server "${parsed.server}" does not support resources`
|
||||
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
|
||||
)
|
||||
}
|
||||
const permissionPatterns = parsed.server
|
||||
? [`mcp:${parsed.server}:*`]
|
||||
: resourceServers.map((server) => `mcp:${server}:*`)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
metadata: parsed.server ? { server: parsed.server } : {},
|
||||
patterns: permissionPatterns,
|
||||
always: permissionPatterns,
|
||||
})
|
||||
|
||||
const resources = Object.values(yield* mcp.resources(parsed.server))
|
||||
const filtered = resources
|
||||
.filter((resource) => !parsed.server || resource.client === parsed.server)
|
||||
.toSorted((a, b) =>
|
||||
(a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare(
|
||||
b.client + "\u0000" + b.name + "\u0000" + b.uri,
|
||||
),
|
||||
)
|
||||
const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2)
|
||||
const truncated = yield* truncate.output(content, {}, input.agent)
|
||||
const output = {
|
||||
title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources",
|
||||
metadata: {
|
||||
count: filtered.length,
|
||||
servers: resourceServers,
|
||||
...(parsed.server ? { server: parsed.server } : {}),
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
output: truncated.content,
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
output,
|
||||
)
|
||||
if (opts.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
tools[READ_MCP_RESOURCE_TOOL] = tool({
|
||||
description:
|
||||
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
|
||||
inputSchema: jsonSchema(
|
||||
ProviderTransform.schema(input.model, {
|
||||
type: "object",
|
||||
properties: {
|
||||
server: {
|
||||
type: "string",
|
||||
description: "MCP server name exactly as returned by list_mcp_resources.",
|
||||
},
|
||||
uri: {
|
||||
type: "string",
|
||||
description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
|
||||
},
|
||||
},
|
||||
required: ["server", "uri"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
),
|
||||
execute(args, opts) {
|
||||
return run.promise(
|
||||
Effect.gen(function* () {
|
||||
const parsed = parseReadMcpResourceArgs(args)
|
||||
const ctx = context(toRecord(args), opts)
|
||||
const clients = yield* mcp.clients()
|
||||
const client = clients[parsed.server]
|
||||
if (!client) {
|
||||
throw new Error(`MCP server "${parsed.server}" is not connected`)
|
||||
}
|
||||
if (!client.getServerCapabilities()?.resources) {
|
||||
throw new Error(`MCP server "${parsed.server}" does not support resources`)
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
metadata: { server: parsed.server, uri: parsed.uri },
|
||||
patterns: [`mcp:${parsed.server}:${parsed.uri}`],
|
||||
always: [`mcp:${parsed.server}:*`],
|
||||
})
|
||||
|
||||
const content = yield* mcp.readResource(parsed.server, parsed.uri)
|
||||
if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
|
||||
|
||||
const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
|
||||
const truncated = yield* truncate.output(formatted.text, {}, input.agent)
|
||||
const output = {
|
||||
title: `MCP resource: ${parsed.uri}`,
|
||||
metadata: {
|
||||
server: parsed.server,
|
||||
uri: parsed.uri,
|
||||
contents: formatted.contents,
|
||||
attachments: formatted.attachments.length,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
output: truncated.content,
|
||||
attachments: formatted.attachments.map((attachment) => ({
|
||||
...attachment,
|
||||
id: PartID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
})),
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
output,
|
||||
)
|
||||
if (opts.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
@@ -163,10 +344,24 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
const { resource } = contentItem
|
||||
if (resource.text) textParts.push(resource.text)
|
||||
if (resource.blob) {
|
||||
const mime = resource.mimeType ?? "application/octet-stream"
|
||||
const size = base64Size(resource.blob)
|
||||
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
||||
textParts.push(
|
||||
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
||||
textParts.push(
|
||||
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
attachments.push({
|
||||
type: "file",
|
||||
mime: resource.mimeType ?? "application/octet-stream",
|
||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||
mime,
|
||||
url: `data:${mime};base64,${resource.blob}`,
|
||||
filename: resource.uri,
|
||||
})
|
||||
}
|
||||
@@ -204,4 +399,94 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
return tools
|
||||
})
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
if (isRecord(value)) return value
|
||||
return {}
|
||||
}
|
||||
|
||||
function parseListMcpResourcesArgs(value: unknown) {
|
||||
const args = toRecord(value)
|
||||
return { server: optionalString(args, "server") }
|
||||
}
|
||||
|
||||
function parseReadMcpResourceArgs(value: unknown) {
|
||||
const args = toRecord(value)
|
||||
return { server: requiredString(args, "server"), uri: requiredString(args, "uri") }
|
||||
}
|
||||
|
||||
function optionalString(args: Record<string, unknown>, key: string) {
|
||||
const value = args[key]
|
||||
if (value === undefined || value === null || value === "") return undefined
|
||||
if (typeof value !== "string") throw new Error(`${key} must be a string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function requiredString(args: Record<string, unknown>, key: string) {
|
||||
const value = optionalString(args, key)
|
||||
if (value) return value
|
||||
throw new Error(`${key} is required`)
|
||||
}
|
||||
|
||||
function formatMcpResource(resource: MCP.Resource) {
|
||||
const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client"))
|
||||
return { ...result, server: resource.client }
|
||||
}
|
||||
|
||||
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
|
||||
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
|
||||
const text: string[] = []
|
||||
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemUri = typeof item.uri === "string" ? item.uri : uri
|
||||
const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
|
||||
if (typeof item.text === "string") {
|
||||
text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
|
||||
continue
|
||||
}
|
||||
if (typeof item.blob === "string") {
|
||||
const size = base64Size(item.blob)
|
||||
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
||||
text.push(
|
||||
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
||||
text.push(
|
||||
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
|
||||
attachments.push({
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${item.blob}`,
|
||||
filename: itemUri,
|
||||
})
|
||||
continue
|
||||
}
|
||||
text.push(`[MCP resource content without text or blob: ${itemUri}]`)
|
||||
}
|
||||
|
||||
return {
|
||||
contents: items.length,
|
||||
attachments,
|
||||
text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
|
||||
}
|
||||
}
|
||||
|
||||
function base64Size(value: string) {
|
||||
const trimmed = value.replace(/\s/g, "")
|
||||
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
|
||||
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
|
||||
return `${Math.ceil(value / (1024 * 1024))} MB`
|
||||
}
|
||||
|
||||
export * as SessionTools from "./tools"
|
||||
|
||||
@@ -260,11 +260,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
|
||||
return tree
|
||||
})
|
||||
|
||||
const ask = Effect.fn("ShellTool.ask")(function* (
|
||||
ctx: Tool.Context,
|
||||
scan: Scan,
|
||||
input: { command: string; description: string },
|
||||
) {
|
||||
const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) {
|
||||
if (scan.dirs.size > 0) {
|
||||
const directories = Array.from(scan.dirs)
|
||||
const globs = directories.map((dir) => {
|
||||
@@ -277,7 +273,6 @@ const ask = Effect.fn("ShellTool.ask")(function* (
|
||||
always: globs,
|
||||
metadata: {
|
||||
command: input.command,
|
||||
description: input.description,
|
||||
directories,
|
||||
patterns: globs,
|
||||
},
|
||||
@@ -291,7 +286,6 @@ const ask = Effect.fn("ShellTool.ask")(function* (
|
||||
always: Array.from(scan.always),
|
||||
metadata: {
|
||||
command: input.command,
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -438,7 +432,6 @@ export const ShellTool = Tool.define(
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
timeout: number
|
||||
description: string
|
||||
},
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
@@ -482,7 +475,6 @@ export const ShellTool = Tool.define(
|
||||
yield* ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -523,7 +515,6 @@ export const ShellTool = Tool.define(
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: last,
|
||||
description: input.description,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -534,7 +525,6 @@ export const ShellTool = Tool.define(
|
||||
return ctx.metadata({
|
||||
metadata: {
|
||||
output: last,
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
}),
|
||||
@@ -593,11 +583,10 @@ export const ShellTool = Tool.define(
|
||||
output += "\n\n<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>"
|
||||
}
|
||||
return {
|
||||
title: input.description,
|
||||
title: input.command,
|
||||
metadata: {
|
||||
output: last || preview(output),
|
||||
exit: code,
|
||||
description: input.description,
|
||||
truncated: cut,
|
||||
...(cut && file ? { outputPath: file } : {}),
|
||||
},
|
||||
@@ -646,7 +635,6 @@ export const ShellTool = Tool.define(
|
||||
cwd,
|
||||
env: yield* shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
@@ -7,30 +7,22 @@ import { ShellID } from "./id"
|
||||
const PS = new Set(["powershell", "pwsh"])
|
||||
const CMD = new Set(["cmd"])
|
||||
|
||||
const descriptions = {
|
||||
bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
|
||||
powershell:
|
||||
'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp',
|
||||
cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp',
|
||||
}
|
||||
|
||||
export type Limits = {
|
||||
maxLines: number
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
export function parameterSchema(description: string) {
|
||||
export function parameterSchema() {
|
||||
return Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "The command to execute" }),
|
||||
timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }),
|
||||
workdir: Schema.optional(Schema.String).annotate({
|
||||
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
}),
|
||||
description: Schema.String.annotate({ description }),
|
||||
})
|
||||
}
|
||||
|
||||
export const Parameters = parameterSchema(descriptions.bash)
|
||||
export const Parameters = parameterSchema()
|
||||
export type Parameters = Schema.Schema.Type<typeof Parameters>
|
||||
|
||||
function renderPrompt(template: string, values: Record<string, string>) {
|
||||
@@ -103,7 +95,6 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
@@ -155,7 +146,6 @@ Before executing the command, please follow these steps:
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
@@ -205,7 +195,6 @@ Before executing the command, please follow these steps:
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
@@ -242,7 +231,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
gitCommandRestriction: "git commands",
|
||||
createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.",
|
||||
createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`,
|
||||
parameterDescription: descriptions.cmd,
|
||||
}
|
||||
}
|
||||
if (isPowerShell) {
|
||||
@@ -264,7 +252,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
## Summary
|
||||
- <1-3 bullet points>
|
||||
'@`,
|
||||
parameterDescription: descriptions.powershell,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -280,7 +267,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>`,
|
||||
parameterDescription: descriptions.bash,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +286,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits,
|
||||
createPrInstruction: selected.createPrInstruction,
|
||||
createPrExample: selected.createPrExample,
|
||||
}),
|
||||
parameters: parameterSchema(selected.parameterDescription),
|
||||
parameters: parameterSchema(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// version (changes per release), so we'd snapshot a moving target.
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { EOL } from "os"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
|
||||
|
||||
@@ -101,7 +100,7 @@ describe("opencode CLI help-text snapshots", () => {
|
||||
Effect.gen(function* () {
|
||||
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
|
||||
expect(topLevel.exitCode).toBe(0)
|
||||
expect(topLevel.stderr.endsWith(EOL)).toBe(true)
|
||||
expect(topLevel.stderr.endsWith("\n")).toBe(true)
|
||||
expect(topLevel.stderr).toContain("--mini")
|
||||
expect(topLevel.stderr).not.toContain("--thinking")
|
||||
expect(topLevel.stderr).not.toContain("--variant")
|
||||
|
||||
@@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("omits the current directory from bash titles", async () => {
|
||||
const out = await setup()
|
||||
|
||||
try {
|
||||
await out.scrollback.append(
|
||||
toolCommit({
|
||||
tool: "bash",
|
||||
phase: "start",
|
||||
toolState: "running",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
command: "pwd",
|
||||
workdir: process.cwd(),
|
||||
},
|
||||
time: { start: 1 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const commits = claim(out.renderer)
|
||||
try {
|
||||
expect(render(commits)).toContain("$ pwd")
|
||||
expect(render(commits)).not.toContain("Running in .")
|
||||
} finally {
|
||||
destroy(commits)
|
||||
}
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders completed bash output with one blank line after the command and before the next group", async () => {
|
||||
const out = await setup()
|
||||
|
||||
@@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be
|
||||
input: {
|
||||
command: "git status",
|
||||
workdir: "/tmp/demo",
|
||||
description: "Show git status",
|
||||
},
|
||||
time: { start: 1 },
|
||||
},
|
||||
@@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be
|
||||
input: {
|
||||
command: "git status",
|
||||
workdir: "/tmp/demo",
|
||||
description: "Show git status",
|
||||
},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
@@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be
|
||||
take()
|
||||
|
||||
const output = lines.join("\n")
|
||||
expect(output).toContain("# Running in /tmp/demo\n$ git status")
|
||||
expect(output).toContain("$ git status\n\nOn branch demo")
|
||||
expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1")
|
||||
expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1")
|
||||
@@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu
|
||||
input: {
|
||||
command: "pwd; ls -la",
|
||||
workdir: "/tmp/demo",
|
||||
description: "Lists current directory files",
|
||||
},
|
||||
time: { start: 1 },
|
||||
},
|
||||
@@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu
|
||||
input: {
|
||||
command: "pwd; ls -la",
|
||||
workdir: "/tmp/demo",
|
||||
description: "Lists current directory files",
|
||||
},
|
||||
output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
|
||||
title: "pwd; ls -la",
|
||||
@@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header
|
||||
input: {
|
||||
command: "ls",
|
||||
workdir: "src/cli/cmd/run",
|
||||
description: "Lists files in run directory",
|
||||
},
|
||||
time: { start: 1 },
|
||||
},
|
||||
@@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header
|
||||
input: {
|
||||
command: "ls",
|
||||
workdir: "src/cli/cmd/run",
|
||||
description: "Lists files in run directory",
|
||||
},
|
||||
output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
|
||||
title: "ls",
|
||||
|
||||
@@ -435,7 +435,6 @@ describe("run session data", () => {
|
||||
title: "",
|
||||
metadata: {
|
||||
output: "/tmp/demo\n",
|
||||
description: "",
|
||||
},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
@@ -490,7 +489,6 @@ describe("run session data", () => {
|
||||
title: "",
|
||||
metadata: {
|
||||
output: "/tmp/demo\n",
|
||||
description: "",
|
||||
},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
|
||||
@@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu
|
||||
title: "",
|
||||
metadata: {
|
||||
output: "account.ts\n",
|
||||
description: "",
|
||||
},
|
||||
time: {
|
||||
start: 200,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
|
||||
import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "node:path"
|
||||
@@ -192,9 +192,12 @@ export function withCliFixture<A, E>(
|
||||
const fs = yield* FSUtil.Service
|
||||
const appProc = yield* AppProcess.Service
|
||||
|
||||
// FileSystem.makeTempDirectoryScoped handles both creation and scope-tied
|
||||
// cleanup — replaces the old mkdir + addFinalizer pair.
|
||||
const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" })
|
||||
const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" })
|
||||
yield* Effect.addFinalizer(() =>
|
||||
fs
|
||||
.remove(home, { recursive: true })
|
||||
.pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore),
|
||||
)
|
||||
|
||||
const configJson = JSON.stringify(testProviderConfig(llm.url))
|
||||
const env = isolatedEnv(home, configJson)
|
||||
@@ -517,5 +520,10 @@ export const cliIt = {
|
||||
name: string,
|
||||
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
|
||||
opts?: number | TestOptions,
|
||||
) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts),
|
||||
) =>
|
||||
(process.platform === "win32" ? test : test.concurrent)(
|
||||
name,
|
||||
() => Effect.runPromise(Effect.scoped(withCliFixture(body))),
|
||||
opts,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ it.instance(
|
||||
|
||||
expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
|
||||
expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"])
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
|
||||
expect(serverState.listToolsCalls).toBe(2)
|
||||
expect(serverState.listPromptsCalls).toBe(2)
|
||||
expect(serverState.listResourcesCalls).toBe(2)
|
||||
@@ -796,7 +796,10 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "resource-server"
|
||||
const serverState = getOrCreateClientState("resource-server")
|
||||
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
|
||||
serverState.resources = [
|
||||
{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" },
|
||||
{ name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" },
|
||||
]
|
||||
|
||||
yield* mcp.add("resource-server", {
|
||||
type: "local",
|
||||
@@ -804,10 +807,10 @@ it.instance(
|
||||
})
|
||||
|
||||
const resources = yield* mcp.resources()
|
||||
expect(Object.keys(resources).length).toBe(1)
|
||||
const key = Object.keys(resources)[0]
|
||||
expect(key).toContain("resource-server")
|
||||
expect(key).toContain("my-resource")
|
||||
expect(Object.keys(resources)).toEqual([
|
||||
"resource-server:file:///test.txt",
|
||||
"resource-server:ui://component-state",
|
||||
])
|
||||
}),
|
||||
),
|
||||
{
|
||||
@@ -863,7 +866,7 @@ it.instance(
|
||||
expect(statusName(result.status, "resource-only-server")).toBe("connected")
|
||||
expect(serverState.listToolsCalls).toBe(0)
|
||||
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"])
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"])
|
||||
expect(serverState.listResourcesCalls).toBe(1)
|
||||
expect(serverState.listPromptsCalls).toBe(0)
|
||||
}),
|
||||
|
||||
@@ -269,7 +269,7 @@ mcpTest.instance(
|
||||
const result = yield* mcp.authenticate("test-oauth-resources")
|
||||
expect(result.status).toBe("connected")
|
||||
expect(listToolsCalls).toBe(0)
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"])
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"])
|
||||
}),
|
||||
),
|
||||
{ config: config("test-oauth-resources") },
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context } from "effect"
|
||||
import { Context, Effect } from "effect"
|
||||
import path from "path"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { pollWithTimeout } from "../lib/effect"
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
@@ -55,17 +56,26 @@ describe("file HttpApi", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(path.join(tmp.path, "hello.txt"), "needle")
|
||||
|
||||
const [text, files, symbols] = await Promise.all([
|
||||
const [text, symbols] = await Promise.all([
|
||||
request(FilePaths.findText, tmp.path, { pattern: "needle" }),
|
||||
request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }),
|
||||
request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
|
||||
])
|
||||
const files = await Effect.runPromise(
|
||||
pollWithTimeout(
|
||||
Effect.promise(async () => {
|
||||
const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" })
|
||||
const body = await response.json()
|
||||
return body.includes("hello.txt") ? { response, body } : undefined
|
||||
}),
|
||||
"file search index was not ready",
|
||||
),
|
||||
)
|
||||
|
||||
expect(text.status).toBe(200)
|
||||
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
|
||||
|
||||
expect(files.status).toBe(200)
|
||||
expect(await files.json()).toContain("hello.txt")
|
||||
expect(files.response.status).toBe(200)
|
||||
expect(files.body).toContain("hello.txt")
|
||||
|
||||
expect(symbols.status).toBe(200)
|
||||
expect(await symbols.json()).toEqual([])
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Server } from "../../src/server/server"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { Effect } from "effect"
|
||||
import { pollWithTimeout } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -24,12 +26,19 @@ describe("reference HttpApi", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const response = await Server.Default().app.request("/api/reference", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
const body = await Effect.runPromise(
|
||||
pollWithTimeout(
|
||||
Effect.promise(async () => {
|
||||
const response = await Server.Default().app.request("/api/reference", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
return body.data.length === 0 ? undefined : body
|
||||
}),
|
||||
"references were not loaded",
|
||||
),
|
||||
)
|
||||
expect(body).toMatchObject({ location: { directory: tmp.path } })
|
||||
expect(body.data).toEqual([
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ import { TestLLMServer } from "../lib/llm-server"
|
||||
import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
@@ -389,7 +389,12 @@ describe("HttpApi SDK", () => {
|
||||
workspaceID,
|
||||
onRequest: (value) => (request = value),
|
||||
})
|
||||
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" }))
|
||||
const found = yield* pollWithTimeout(
|
||||
call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe(
|
||||
Effect.map((result) => (result.data?.data.length ? result : undefined)),
|
||||
),
|
||||
"SDK file search index was not ready",
|
||||
)
|
||||
const url = new URL(request!.url)
|
||||
|
||||
expect(found.response.status).toBe(200)
|
||||
|
||||
@@ -1250,7 +1250,7 @@ describe("session.compaction.process", () => {
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
|
||||
yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds"))
|
||||
const start = Date.now()
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
|
||||
@@ -1263,6 +1263,7 @@ describe("session.compaction.process", () => {
|
||||
}).pipe(withCompaction({ llm: stub.layer }))
|
||||
},
|
||||
{ git: true },
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
|
||||
@@ -1242,7 +1242,7 @@ it.instance(
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
3_000,
|
||||
10_000,
|
||||
)
|
||||
|
||||
// Queue semantics
|
||||
@@ -1630,7 +1630,7 @@ it.instance(
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
}),
|
||||
{ git: true },
|
||||
3_000,
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -1669,7 +1669,7 @@ it.instance(
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
}),
|
||||
{ git: true },
|
||||
3_000,
|
||||
10_000,
|
||||
)
|
||||
|
||||
unix(
|
||||
@@ -1811,7 +1811,6 @@ unix(
|
||||
yield* llm.tool("bash", {
|
||||
command:
|
||||
'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30',
|
||||
description: "Print many lines",
|
||||
timeout: 30_000,
|
||||
workdir: path.resolve(dir),
|
||||
})
|
||||
|
||||
@@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
|
||||
const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
|
||||
yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", {
|
||||
command,
|
||||
description: "create test file",
|
||||
})
|
||||
yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done")
|
||||
|
||||
|
||||
@@ -24,23 +24,6 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
|
||||
"description": "The command to execute",
|
||||
"type": "string",
|
||||
},
|
||||
"description": {
|
||||
"description":
|
||||
"Clear, concise description of what this command does in 5-10 words. Examples:
|
||||
Input: ls
|
||||
Output: Lists files in current directory
|
||||
|
||||
Input: git status
|
||||
Output: Shows working tree status
|
||||
|
||||
Input: npm install
|
||||
Output: Installs package dependencies
|
||||
|
||||
Input: mkdir foo
|
||||
Output: Creates directory 'foo'"
|
||||
,
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Optional timeout in milliseconds",
|
||||
"exclusiveMinimum": 0,
|
||||
@@ -55,7 +38,6 @@ Output: Creates directory 'foo'"
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"description",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
@@ -106,19 +106,16 @@ describe("tool parameters", () => {
|
||||
})
|
||||
|
||||
describe("shell", () => {
|
||||
test("accepts minimum: command + description", () => {
|
||||
expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
|
||||
test("accepts command", () => {
|
||||
expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" })
|
||||
})
|
||||
test("accepts optional timeout + workdir", () => {
|
||||
const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
|
||||
const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" })
|
||||
expect(parsed.timeout).toBe(5000)
|
||||
expect(parsed.workdir).toBe("/tmp")
|
||||
})
|
||||
test("rejects missing description", () => {
|
||||
expect(accepts(Shell, { command: "ls" })).toBe(false)
|
||||
})
|
||||
test("rejects missing command", () => {
|
||||
expect(accepts(Shell, { description: "list" })).toBe(false)
|
||||
expect(accepts(Shell, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -182,7 +182,6 @@ describe("tool.shell", () => {
|
||||
Effect.gen(function* () {
|
||||
const result = yield* run({
|
||||
command: "echo test",
|
||||
description: "Echo test message",
|
||||
})
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
expect(result.metadata.output).toContain("test")
|
||||
@@ -204,7 +203,6 @@ describe("tool.shell", () => {
|
||||
const result = yield* bash.execute(
|
||||
{
|
||||
command: "echo fallback",
|
||||
description: "Echo fallback text",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
@@ -227,7 +225,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -249,7 +246,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -273,7 +269,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
||||
description: "Check PowerShell conditional",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -303,7 +298,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: "Remove-Item -Recurse tmp",
|
||||
description: "Remove a temp directory",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -331,7 +325,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: `cat ${file}`,
|
||||
description: "Read wildcard path",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -359,7 +352,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: `echo $(cat "${file}")`,
|
||||
description: "Read nested bash file",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -389,7 +381,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
|
||||
description: "Copy Windows ini",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -415,7 +406,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: `Write-Output $(Get-Content ${file})`,
|
||||
description: "Read nested PowerShell file",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -446,7 +436,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: 'Get-Content "C:../outside.txt"',
|
||||
description: "Read drive-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -474,7 +463,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: 'Get-Content "$HOME/.ssh/config"',
|
||||
description: "Read home config",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -503,7 +491,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: 'Get-Content "$PWD/../outside.txt"',
|
||||
description: "Read pwd-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -531,7 +518,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: 'Get-Content "$PSHOME/outside.txt"',
|
||||
description: "Read pshome file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -567,7 +553,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
|
||||
description: "Read Windows ini with missing env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -598,7 +583,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "Get-Content $env:WINDIR/win.ini",
|
||||
description: "Read Windows ini from env",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -626,7 +610,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
|
||||
description: "Read Windows ini from FileSystem provider",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -655,7 +638,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: "Get-Content ${env:WINDIR}/win.ini",
|
||||
description: "Read Windows ini from braced env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -682,7 +664,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "Set-Location C:/Windows",
|
||||
description: "Change location",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -710,7 +691,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "Write-Output ('a' * 3)",
|
||||
description: "Write repeated text",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -736,7 +716,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
|
||||
description: "Read Windows ini with cmd",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -761,7 +740,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -786,7 +764,6 @@ describe("tool.shell permissions", () => {
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: os.tmpdir(),
|
||||
description: "Echo from temp dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -817,7 +794,6 @@ describe("tool.shell permissions", () => {
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -850,7 +826,6 @@ describe("tool.shell permissions", () => {
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/tmp",
|
||||
description: "Echo from Git Bash tmp",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -878,7 +853,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: "cat /tmp/opencode-does-not-exist",
|
||||
description: "Read Git Bash tmp file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -910,7 +884,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* fail(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
@@ -922,7 +895,6 @@ describe("tool.shell permissions", () => {
|
||||
expect(extDirReq!.always).toContain(expected)
|
||||
expect(extDirReq!.metadata).toMatchObject({
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
directories: [outerTmp],
|
||||
patterns: [expected],
|
||||
})
|
||||
@@ -942,7 +914,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: `rm -rf ${path.join(tmp, "nested")}`,
|
||||
description: "Remove nested dir",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -963,7 +934,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -985,7 +955,6 @@ describe("tool.shell permissions", () => {
|
||||
yield* run(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
@@ -1004,12 +973,9 @@ describe("tool.shell permissions", () => {
|
||||
Effect.gen(function* () {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
expect(
|
||||
yield* fail(
|
||||
{ command: "echo test > output.txt", description: "Redirect test output" },
|
||||
capture(requests, err),
|
||||
),
|
||||
).toMatchObject({ message: err.message })
|
||||
expect(yield* fail({ command: "echo test > output.txt" }, capture(requests, err))).toMatchObject({
|
||||
message: err.message,
|
||||
})
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.patterns).toContain("echo test > output.txt")
|
||||
@@ -1025,7 +991,7 @@ describe("tool.shell permissions", () => {
|
||||
tmp,
|
||||
Effect.gen(function* () {
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
yield* run({ command: "ls -la", description: "List" }, capture(requests))
|
||||
yield* run({ command: "ls -la" }, capture(requests))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.always[0]).toBe("ls *")
|
||||
@@ -1047,7 +1013,6 @@ describe("tool.shell abort", () => {
|
||||
const res = yield* run(
|
||||
{
|
||||
command: `echo before && sleep 30`,
|
||||
description: "Long running command",
|
||||
},
|
||||
{
|
||||
...ctx,
|
||||
@@ -1078,7 +1043,6 @@ describe("tool.shell abort", () => {
|
||||
Effect.gen(function* () {
|
||||
const result = yield* run({
|
||||
command: `sleep 60`,
|
||||
description: "Timeout test",
|
||||
timeout: 500,
|
||||
})
|
||||
expect(result.output).toContain("shell tool terminated command after exceeding timeout")
|
||||
@@ -1099,7 +1063,6 @@ describe("tool.shell abort", () => {
|
||||
const result = yield* tool.execute(
|
||||
{
|
||||
command: `sleep 60`,
|
||||
description: "Default timeout test",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
@@ -1116,7 +1079,6 @@ describe("tool.shell abort", () => {
|
||||
Effect.gen(function* () {
|
||||
const result = yield* run({
|
||||
command: `echo stdout_msg && echo stderr_msg >&2`,
|
||||
description: "Stderr test",
|
||||
})
|
||||
expect(result.output).toContain("stdout_msg")
|
||||
expect(result.output).toContain("stderr_msg")
|
||||
@@ -1132,7 +1094,6 @@ describe("tool.shell abort", () => {
|
||||
Effect.gen(function* () {
|
||||
const result = yield* run({
|
||||
command: `exit 42`,
|
||||
description: "Non-zero exit",
|
||||
})
|
||||
expect(result.metadata.exit).toBe(42)
|
||||
}),
|
||||
@@ -1147,7 +1108,6 @@ describe("tool.shell abort", () => {
|
||||
const result = yield* run(
|
||||
{
|
||||
command: `echo first && sleep 0.1 && echo second`,
|
||||
description: "Streaming test",
|
||||
},
|
||||
{
|
||||
...ctx,
|
||||
@@ -1174,7 +1134,6 @@ describe("tool.shell truncation", () => {
|
||||
const lineCount = Truncate.MAX_LINES + 500
|
||||
const result = yield* run({
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines exceeding limit",
|
||||
})
|
||||
mustTruncate(result)
|
||||
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
|
||||
@@ -1190,7 +1149,6 @@ describe("tool.shell truncation", () => {
|
||||
const byteCount = Truncate.MAX_BYTES + 10000
|
||||
const result = yield* run({
|
||||
command: fill("bytes", byteCount),
|
||||
description: "Generate bytes exceeding limit",
|
||||
})
|
||||
mustTruncate(result)
|
||||
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
|
||||
@@ -1205,7 +1163,6 @@ describe("tool.shell truncation", () => {
|
||||
Effect.gen(function* () {
|
||||
const result = yield* run({
|
||||
command: fill("lines", 1),
|
||||
description: "Generate one line",
|
||||
})
|
||||
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
|
||||
expect(result.output).toContain("1")
|
||||
@@ -1220,7 +1177,6 @@ describe("tool.shell truncation", () => {
|
||||
const lineCount = Truncate.MAX_LINES + 100
|
||||
const result = yield* run({
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines for file check",
|
||||
})
|
||||
mustTruncate(result)
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { SDKProvider, useSDK } from "./context/sdk"
|
||||
import { StartupLoading } from "./component/startup-loading"
|
||||
import { SyncProvider, useSync } from "./context/sync"
|
||||
import { DataProvider } from "./context/data"
|
||||
import { LocationProvider } from "./context/location"
|
||||
import { LocalProvider, useLocal } from "./context/local"
|
||||
import { DialogModel } from "./component/dialog-model"
|
||||
import { useConnected } from "./component/use-connected"
|
||||
@@ -303,10 +304,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
/>
|
||||
<LocationProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
/>
|
||||
</LocationProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useData } from "../../context/data"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
@@ -21,6 +22,7 @@ import type { PromptInfo } from "../../prompt/history"
|
||||
import { useFrecency } from "../../prompt/frecency"
|
||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
@@ -94,6 +96,7 @@ export function Autocomplete(props: {
|
||||
const frecency = useFrecency()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
@@ -236,16 +239,18 @@ export function Autocomplete(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
|
||||
const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "")
|
||||
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
function createFilePart(
|
||||
item: FileSystemEntry,
|
||||
filePath: string,
|
||||
lineRange?: { startLine: number; endLine?: number },
|
||||
) {
|
||||
const urlObj = pathToFileURL(filePath)
|
||||
const filename =
|
||||
lineRange && !item.endsWith("/")
|
||||
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
|
||||
: item
|
||||
lineRange && item.type !== "directory"
|
||||
? `${item.path}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
|
||||
: item.path
|
||||
|
||||
if (lineRange && !item.endsWith("/")) {
|
||||
if (lineRange && item.type !== "directory") {
|
||||
urlObj.searchParams.set("start", String(lineRange.startLine))
|
||||
if (lineRange.endLine !== undefined) {
|
||||
urlObj.searchParams.set("end", String(lineRange.endLine))
|
||||
@@ -254,10 +259,9 @@ export function Autocomplete(props: {
|
||||
|
||||
return {
|
||||
filename,
|
||||
url: urlObj.href,
|
||||
part: {
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
mime: item.mime,
|
||||
filename,
|
||||
url: urlObj.href,
|
||||
source: {
|
||||
@@ -267,7 +271,7 @@ export function Autocomplete(props: {
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
path: item,
|
||||
path: item.path,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -284,7 +288,7 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
function normalizeMentionPath(filePath: string) {
|
||||
const baseDir = sync.path.directory || paths.cwd
|
||||
const baseDir = location()?.directory || sync.path.directory || paths.cwd
|
||||
const absolute = path.resolve(filePath)
|
||||
const relative = path.relative(baseDir, absolute)
|
||||
|
||||
@@ -301,7 +305,11 @@ export function Autocomplete(props: {
|
||||
startLine: input.lineStart,
|
||||
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
|
||||
}
|
||||
const { filename, part } = createFilePart(item, lineRange)
|
||||
const { filename, part } = createFilePart(
|
||||
{ path: item, type: "file", mime: "text/plain" },
|
||||
input.filePath,
|
||||
lineRange,
|
||||
)
|
||||
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
||||
|
||||
setStore("visible", false)
|
||||
@@ -310,17 +318,20 @@ export function Autocomplete(props: {
|
||||
}
|
||||
|
||||
const [files] = createResource(
|
||||
() => search(),
|
||||
async (query) => {
|
||||
() => ({ query: search(), location: location() }),
|
||||
async (input) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
if (referenceMatch()) return []
|
||||
const { lineRange, baseQuery } = extractLineRange(query ?? "")
|
||||
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
|
||||
|
||||
// Get files from SDK
|
||||
const result = await sdk.client.v2.fs.find({
|
||||
query: baseQuery,
|
||||
limit: "20",
|
||||
location: { workspace: project.workspace.current() },
|
||||
location: {
|
||||
directory: input.location?.directory,
|
||||
workspace: input.location?.workspaceID ?? project.workspace.current(),
|
||||
},
|
||||
})
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
@@ -331,7 +342,11 @@ export function Autocomplete(props: {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...result.data.data.map((item): AutocompleteOption => {
|
||||
const { filename, url, part } = createFilePart(item.path, lineRange)
|
||||
const { filename, part } = createFilePart(
|
||||
item,
|
||||
path.join(result.data.location.directory, item.path),
|
||||
lineRange,
|
||||
)
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
value: filename,
|
||||
@@ -758,7 +773,7 @@ export function Autocomplete(props: {
|
||||
</text>
|
||||
<Show when={option().description}>
|
||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
|
||||
{option().description}
|
||||
{" " + option().description?.trimStart()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
|
||||
const context = createContext<Accessor<LocationRef | undefined>>()
|
||||
|
||||
export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) {
|
||||
return <context.Provider value={() => props.location}>{props.children}</context.Provider>
|
||||
}
|
||||
|
||||
export function useLocation() {
|
||||
const value = useContext(context)
|
||||
if (!value) throw new Error("Location context must be used within a LocationProvider")
|
||||
return value
|
||||
}
|
||||
@@ -1,31 +1,15 @@
|
||||
import path from "path"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useLocation } from "./location"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
|
||||
const context = createContext<{
|
||||
path: () => string
|
||||
format: (input?: string) => string
|
||||
}>()
|
||||
|
||||
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
|
||||
const paths = useTuiPaths()
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
path: () => props.path || paths.cwd,
|
||||
format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePathFormatter() {
|
||||
const value = useContext(context)
|
||||
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
|
||||
return value
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
return {
|
||||
path: () => location()?.directory || paths.cwd,
|
||||
format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home),
|
||||
}
|
||||
}
|
||||
|
||||
function formatPath(input: string | undefined, base: string, home: string) {
|
||||
|
||||
@@ -39,11 +39,11 @@ const ROUTE = "diff"
|
||||
const MIN_SPLIT_WIDTH = 100
|
||||
const FILE_TREE_WIDTH = 32
|
||||
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
|
||||
const WORKING_TREE_DIFF_CONTEXT_LINES = 12
|
||||
const VCS_DIFF_CONTEXT_LINES = 12
|
||||
const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
|
||||
const KV_SINGLE_PATCH = "diff_viewer_single_patch"
|
||||
const KV_VIEW = "diff_viewer_view"
|
||||
type DiffMode = "git" | "last-turn"
|
||||
type DiffMode = "git" | "branch" | "last-turn"
|
||||
type DiffViewerFocus = "patches" | "files"
|
||||
type DiffView = "split" | "unified"
|
||||
type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
|
||||
@@ -82,6 +82,12 @@ function storedView(value: unknown): DiffView | undefined {
|
||||
if (value === "split" || value === "unified") return value
|
||||
}
|
||||
|
||||
function diffSourceLabel(mode: DiffMode) {
|
||||
if (mode === "last-turn") return "last turn"
|
||||
if (mode === "branch") return "main branch"
|
||||
return "working tree"
|
||||
}
|
||||
|
||||
function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const themeState = useTheme()
|
||||
@@ -117,7 +123,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
}
|
||||
|
||||
const result = await props.api.client.vcs.diff(
|
||||
{ directory: input.directory, mode: "git", context: WORKING_TREE_DIFF_CONTEXT_LINES },
|
||||
{ directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
return normalizeDiffs(result.data ?? [])
|
||||
@@ -681,6 +687,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
value: "git" as const,
|
||||
description: "Show current git changes",
|
||||
},
|
||||
{
|
||||
title: "Main branch",
|
||||
value: "branch" as const,
|
||||
description: "Show changes compared to main branch",
|
||||
},
|
||||
{
|
||||
title: "Last turn",
|
||||
value: "last-turn" as const,
|
||||
@@ -736,7 +747,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
<PanelGroup axis="y" width="100%" height="100%">
|
||||
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||
<text fg={theme().text}>Diff </text>
|
||||
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
|
||||
<text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme().textMuted}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
@@ -971,7 +982,7 @@ function DiffViewerHelpDialog() {
|
||||
{
|
||||
shortcut: useCommandShortcut("diff.switch_source"),
|
||||
action: "Switch source",
|
||||
description: "Choose working tree or last-turn changes",
|
||||
description: "Choose working tree, main branch, or last-turn changes",
|
||||
},
|
||||
{
|
||||
shortcut: useCommandShortcut("diff.toggle_view"),
|
||||
|
||||
@@ -80,7 +80,8 @@ import { usePluginRuntime } from "../../plugin/runtime"
|
||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { LocationProvider } from "../../context/location"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -193,6 +194,10 @@ export function Session() {
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID))
|
||||
const location = createMemo(() => {
|
||||
const current = session()
|
||||
return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const title = Locale.truncate(session()?.title ?? "", 50)
|
||||
@@ -1138,7 +1143,7 @@ export function Session() {
|
||||
createEffect(on(() => route.sessionID, toBottom))
|
||||
|
||||
return (
|
||||
<PathFormatterProvider path={session()?.directory}>
|
||||
<LocationProvider location={location()}>
|
||||
<context.Provider
|
||||
value={{
|
||||
get width() {
|
||||
@@ -1338,7 +1343,7 @@ export function Session() {
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
</PathFormatterProvider>
|
||||
</LocationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1989,7 +1994,7 @@ export function InlineToolRow(props: {
|
||||
}
|
||||
|
||||
function BlockTool(props: {
|
||||
title: string
|
||||
title?: string
|
||||
children: JSX.Element
|
||||
onClick?: () => void
|
||||
part?: ToolPart
|
||||
@@ -2018,15 +2023,19 @@ function BlockTool(props: {
|
||||
props.onClick?.()
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{props.title}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
|
||||
<Show when={props.title}>
|
||||
{(title) => (
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{title()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
{props.children}
|
||||
<Show when={error()}>
|
||||
@@ -2054,15 +2063,15 @@ function Shell(props: ToolProps) {
|
||||
const workdirDisplay = createMemo(() => {
|
||||
const workdir = stringValue(props.input.workdir)
|
||||
if (!workdir || workdir === ".") return undefined
|
||||
return pathFormatter.format(workdir)
|
||||
const formatted = pathFormatter.format(workdir)
|
||||
if (formatted === ".") return undefined
|
||||
return formatted
|
||||
})
|
||||
|
||||
const title = createMemo(() => {
|
||||
const desc = stringValue(props.input.description) ?? "Shell"
|
||||
const wd = workdirDisplay()
|
||||
if (!wd) return `# ${desc}`
|
||||
if (desc.includes(wd)) return `# ${desc}`
|
||||
return `# ${desc} in ${wd}`
|
||||
if (!wd) return
|
||||
return `# Running in ${wd}`
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -2071,11 +2080,12 @@ function Shell(props: ToolProps) {
|
||||
<BlockTool
|
||||
title={title()}
|
||||
part={props.part}
|
||||
spinner={isRunning()}
|
||||
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>$ {stringValue(props.input.command)}</text>
|
||||
<Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
|
||||
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
|
||||
</Show>
|
||||
<Show when={output()}>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
</Show>
|
||||
|
||||
@@ -269,12 +269,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
}
|
||||
|
||||
if (permission === "bash") {
|
||||
const title =
|
||||
typeof data.description === "string" && data.description ? data.description : "Shell command"
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
return {
|
||||
icon: "#",
|
||||
title,
|
||||
title: "Shell command",
|
||||
body: (
|
||||
<Show when={command}>
|
||||
<box paddingLeft={1}>
|
||||
|
||||
@@ -27,8 +27,6 @@ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool
|
||||
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
|
||||
"
|
||||
|
||||
# List files
|
||||
|
||||
$ ls
|
||||
|
||||
file.ts
|
||||
|
||||
@@ -98,14 +98,15 @@ test("brackets navigate diff hunks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
|
||||
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) {
|
||||
const commands = new Map<
|
||||
string,
|
||||
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
|
||||
>()
|
||||
let current = startRoute
|
||||
let current = initialRoute ?? startRoute
|
||||
let renderDiff: TuiRouteDefinition["render"] | undefined
|
||||
let vcsDiffInput: unknown
|
||||
let sessionDiffInput: unknown
|
||||
const config = createTuiResolvedConfig()
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
@@ -124,7 +125,12 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
|
||||
return { data: vcsDiff }
|
||||
},
|
||||
},
|
||||
session: { diff: async () => ({ data: [] }) },
|
||||
session: {
|
||||
diff: async (input: unknown) => {
|
||||
sessionDiffInput = input
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
} as unknown as TuiPluginApi["client"],
|
||||
state: {
|
||||
session: {
|
||||
@@ -149,7 +155,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
|
||||
} satisfies TuiPluginApi
|
||||
|
||||
void diffViewerPlugin.tui(api, undefined, pluginMeta)
|
||||
commands.get("diff.open")?.run?.({} as never)
|
||||
if (!initialRoute) commands.get("diff.open")?.run?.({} as never)
|
||||
|
||||
return (
|
||||
<TestTuiContexts>
|
||||
@@ -173,6 +179,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
|
||||
commands,
|
||||
current: () => current,
|
||||
vcsDiffInput: () => vcsDiffInput,
|
||||
sessionDiffInput: () => sessionDiffInput,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +208,40 @@ const session = {
|
||||
},
|
||||
} satisfies Session
|
||||
|
||||
test("branch diff source requests branch VCS diff", async () => {
|
||||
const viewer = await renderDiffViewer([], 20, {
|
||||
name: "diff",
|
||||
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
name: "diff",
|
||||
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "branch", context: 12 })
|
||||
expect(viewer.sessionDiffInput()).toBeUndefined()
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("last-turn diff source requests session diff", async () => {
|
||||
const viewer = await renderDiffViewer([], 20, {
|
||||
name: "diff",
|
||||
params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
|
||||
})
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
name: "diff",
|
||||
params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
|
||||
})
|
||||
expect(viewer.sessionDiffInput()).toEqual({ sessionID: "session-1", messageID: "message-1" })
|
||||
expect(viewer.vcsDiffInput()).toBeUndefined()
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function waitForCommand(
|
||||
app: Awaited<ReturnType<typeof testRender>>,
|
||||
commands: Map<string, unknown>,
|
||||
|
||||
@@ -62,7 +62,6 @@ function ShellOutput() {
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
>
|
||||
<text paddingLeft={3}># List files</text>
|
||||
<box gap={1}>
|
||||
<text>$ ls</text>
|
||||
<text>file.ts</text>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "OpenCode",
|
||||
"short_name": "OpenCode",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
@@ -15,7 +18,7 @@
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#080808",
|
||||
"background_color": "#080808",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js"
|
||||
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js"
|
||||
import { animate, type AnimationPlaybackControls } from "motion"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -24,7 +24,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
|
||||
export interface BasicToolProps {
|
||||
icon: IconProps["name"]
|
||||
trigger: TriggerTitle | JSX.Element
|
||||
trigger: TriggerTitle | JSX.Element | ((open: Accessor<boolean>) => JSX.Element)
|
||||
children?: JSX.Element
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
@@ -89,6 +89,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
const ready = () => state.ready
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
const hasChildren = () => (props.defer ? "children" in props : props.children)
|
||||
const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined
|
||||
|
||||
let cancelReady: (() => void) | undefined
|
||||
|
||||
@@ -187,6 +188,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<Switch>
|
||||
<Match when={dynamicTrigger !== undefined}>{dynamicTrigger}</Match>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(title) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
|
||||
@@ -288,12 +288,6 @@ export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
const finishSelection = (range: SelectedLineRange) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
cancelDraft()
|
||||
}
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
@@ -310,7 +304,6 @@ export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
|
||||
openEditor,
|
||||
hoverComment,
|
||||
cancelDraft,
|
||||
finishSelection,
|
||||
select: setSelected,
|
||||
reset,
|
||||
}
|
||||
@@ -322,10 +315,9 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T>,
|
||||
@@ -333,10 +325,9 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
|
||||
@@ -426,7 +417,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
}),
|
||||
})
|
||||
|
||||
const renderHoverUtility = createLineCommentHoverRenderer({
|
||||
const renderGutterUtility = createLineCommentGutterRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => {
|
||||
if (note.opened()) return null
|
||||
@@ -452,11 +443,6 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
return
|
||||
}
|
||||
|
||||
note.finishSelection(range)
|
||||
}
|
||||
|
||||
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
note.openDraft(range)
|
||||
}
|
||||
|
||||
@@ -464,10 +450,9 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
note,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderHoverUtility,
|
||||
renderGutterUtility,
|
||||
onLineSelected,
|
||||
onLineSelectionEnd,
|
||||
onLineNumberSelectionEnd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,7 +554,7 @@ export function createManagedLineCommentAnnotationRenderer<T>(props: {
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentHoverRenderer(props: {
|
||||
export function createLineCommentGutterRenderer(props: {
|
||||
label: string
|
||||
getSelectedRange: Accessor<SelectedLineRange | null>
|
||||
onOpenDraft: (range: SelectedLineRange) => void
|
||||
|
||||
@@ -422,7 +422,7 @@ export function getToolInfo(
|
||||
return {
|
||||
icon: "console",
|
||||
title: i18n.t("ui.tool.shell"),
|
||||
subtitle: input.description,
|
||||
subtitle: input.command,
|
||||
}
|
||||
case "edit":
|
||||
return {
|
||||
@@ -1905,18 +1905,18 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
trigger={
|
||||
trigger={(open) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending() && props.input.description}>
|
||||
<ShellSubmessage text={props.input.description} animate={sawPending} />
|
||||
<Show when={!pending() && !open() && props.input.command}>
|
||||
<ShellSubmessage text={props.input.command} animate={sawPending} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div data-component="bash-output">
|
||||
<div data-slot="bash-copy">
|
||||
|
||||
@@ -386,7 +386,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
>
|
||||
<div data-slot="session-review-container" class={props.classes?.container}>
|
||||
<Show when={hasDiffs()} fallback={props.empty}>
|
||||
<div class="pb-6">
|
||||
<div data-slot="session-review-list" class="pb-6">
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
@@ -620,13 +620,14 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
props.onDiffRendered?.()
|
||||
}}
|
||||
enableLineSelection={props.onLineComment != null}
|
||||
enableHoverUtility={props.onLineComment != null}
|
||||
enableGutterUtility={props.onLineComment != null}
|
||||
onLineSelected={handleLineSelected}
|
||||
onLineSelectionEnd={handleLineSelectionEnd}
|
||||
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
|
||||
renderGutterUtility={
|
||||
props.onLineComment ? commentsUi.renderGutterUtility : undefined
|
||||
}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
media={{
|
||||
|
||||
@@ -316,10 +316,10 @@ const TOOL_SAMPLES = {
|
||||
},
|
||||
bash: {
|
||||
tool: "bash",
|
||||
input: { command: "bun test --filter session", description: "Run session tests" },
|
||||
input: { command: "bun test --filter session" },
|
||||
output:
|
||||
"bun test v1.3.14\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s",
|
||||
title: "Run session tests",
|
||||
title: "bun test --filter session",
|
||||
metadata: { command: "bun test --filter session" },
|
||||
},
|
||||
edit: {
|
||||
|
||||
@@ -152,10 +152,11 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da
|
||||
ensureThemeStyleElement().textContent = fullCss
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
|
||||
|
||||
// Update theme-color meta tag to match light/dark mode
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute("content", isDark ? "#131010" : "#F8F7F7")
|
||||
if (meta) meta.setAttribute("content", isDark ? "#080808" : "#fafafa")
|
||||
}
|
||||
|
||||
function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
[data-component="button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not(
|
||||
[data-expanded]
|
||||
) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
@@ -140,6 +142,10 @@
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost"]:where([data-expanded]):not(:disabled) {
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -155,7 +161,9 @@
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
|
||||
[data-expanded]
|
||||
) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
@@ -163,6 +171,15 @@
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:where([data-expanded]):not(:disabled) {
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:where([data-expanded]):not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -112,7 +112,9 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not(
|
||||
[data-expanded]
|
||||
) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
@@ -131,7 +133,9 @@
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
|
||||
[data-expanded]
|
||||
) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
@@ -140,6 +144,11 @@
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:where([data-expanded]):not(:disabled) {
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { codeToHtml } from "shiki"
|
||||
interface Props {
|
||||
command: string
|
||||
output: string
|
||||
description?: string
|
||||
expand?: boolean
|
||||
}
|
||||
|
||||
@@ -45,7 +44,7 @@ export function ContentBash(props: Props) {
|
||||
<div class={style.root} data-expanded={expanded() || props.expand === true ? true : undefined}>
|
||||
<div data-slot="body">
|
||||
<div data-slot="header">
|
||||
<span>{props.description}</span>
|
||||
<span>Shell</span>
|
||||
</div>
|
||||
<div data-slot="content">
|
||||
<div innerHTML={commandHtml()} />
|
||||
|
||||
@@ -616,7 +616,6 @@ export function BashTool(props: ToolProps) {
|
||||
<ContentBash
|
||||
command={props.state.input.command}
|
||||
output={props.state.metadata.output ?? props.state.metadata?.stdout}
|
||||
description={props.state.metadata.description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| 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` |
|
||||
@@ -132,6 +133,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| GLM 5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
|
||||
@@ -99,6 +99,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| 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` |
|
||||
@@ -139,6 +140,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| GLM 5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
|
||||
@@ -99,6 +99,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| 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` |
|
||||
@@ -139,6 +140,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| GLM 5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
|
||||
@@ -90,6 +90,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| 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` |
|
||||
@@ -128,6 +129,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| GLM 5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user