Merge branch 'dev' into patch-2

pull/12294/head
David 2026-02-07 17:59:03 +00:00 committed by GitHub
commit ea547cf78f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 468 additions and 233 deletions

View File

@ -497,6 +497,9 @@
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
},
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
@ -516,7 +519,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.5",
"@types/bun": "1.3.8",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
@ -1825,7 +1828,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@ -2135,7 +2138,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],

View File

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768393167,
"narHash": "sha256-n2063BRjHde6DqAz2zavhOOiLUwA3qXt7jQYHyETjX8=",
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2f594d5af95d4fdac67fba60376ec11e482041cb",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {

View File

@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-FMrW0aXYOgRe3ginr4l1LwCszsD/r5CQkvRU6HHA7iw=",
"aarch64-linux": "sha256-NZTtIsFZshWOp5mVFvrcVeHUlx62QcsSJKPYjwPhmYk=",
"aarch64-darwin": "sha256-6cWt8KaqojTJ/b3WSYb3dDPTNuKBDt9Fxx6p/WGBnik=",
"x86_64-darwin": "sha256-F6zuxV34RQ9RTjH0c22rGZaPrhemhRUPi+OkF+Y0ytM="
"x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=",
"aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=",
"aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=",
"x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q="
}
}

View File

@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"packageManager": "bun@1.3.8",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
@ -23,7 +23,7 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.5",
"@types/bun": "1.3.8",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@ -100,5 +100,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:"
},
"patchedDependencies": {}
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
}
}

View File

@ -223,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
value={store.startup}
onChange={(v) => setStore("startup", v)}
spellcheck={false}
class="max-h-40 w-full font-mono text-xs no-scrollbar"
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>

View File

@ -19,6 +19,14 @@ import {
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
function pathToFileUrl(filepath: string): string {
const encodedPath = filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
return `file://${encodedPath}`
}
type Kind = "add" | "del" | "mix"
type Filter = {
@ -247,7 +255,7 @@ export default function FileTree(props: {
onDragStart={(e: DragEvent) => {
if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
const dragImage = document.createElement("div")

View File

@ -1023,7 +1023,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
valueClass="truncate"
variant="ghost"
/>

View File

@ -30,6 +30,12 @@ type BuildRequestPartsInput = {
const absolute = (directory: string, path: string) =>
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
const encodeFilePath = (filepath: string): string =>
filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
@ -99,7 +105,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url: `file://${path}${fileQuery(attachment.selection)}`,
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: getFilename(attachment.path),
source: {
type: "file",
@ -129,7 +135,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
const used = new Set(files.map((part) => part.url))
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const url = `file://${path}${fileQuery(item.selection)}`
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(url)) return []
used.add(url)

View File

@ -544,11 +544,7 @@ export function SessionHeader() {
<Button
variant="ghost"
class="group/file-tree-toggle size-6 p-0"
onClick={() => {
const opening = !layout.fileTree.opened()
if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.toggle()
}}
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"

View File

@ -44,7 +44,7 @@ function groupFor(id: string): KeybindGroup {
if (id === PALETTE_ID) return "General"
if (id.startsWith("terminal.")) return "Terminal"
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
if (id.startsWith("file.")) return "Navigation"
if (id.startsWith("file.") || id.startsWith("fileTree.")) return "Navigation"
if (id.startsWith("prompt.")) return "Prompt"
if (
id.startsWith("session.") ||

View File

@ -72,12 +72,27 @@ export function unquoteGitPath(input: string) {
return new TextDecoder().decode(new Uint8Array(bytes))
}
export function decodeFilePath(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
export function encodeFilePath(filepath: string): string {
return filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
}
export function createPathHelpers(scope: () => string) {
const normalize = (input: string) => {
const root = scope()
const prefix = root.endsWith("/") ? root : root + "/"
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
if (path.startsWith(prefix)) {
path = path.slice(prefix.length)
@ -100,7 +115,7 @@ export function createPathHelpers(scope: () => string) {
const tab = (input: string) => {
const path = normalize(input)
return `file://${path}`
return `file://${encodeFilePath(path)}`
}
const pathFromTab = (tabValue: string) => {

View File

@ -233,7 +233,15 @@ export default function Page() {
}
const isDesktop = createMediaQuery("(min-width: 768px)")
const centered = createMemo(() => isDesktop() && !view().reviewPanel.opened())
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
if (desktopReviewOpen()) return `${layout.session.width()}px`
return `calc(100% - ${layout.fileTree.width()}px)`
})
const centered = createMemo(() => isDesktop() && !desktopSidePanelOpen())
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
@ -252,12 +260,18 @@ export default function Page() {
return next
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (path) file.load(path)
if (!path) return
file.load(path)
openReviewPanel()
}
createEffect(() => {
@ -1085,6 +1099,7 @@ export default function Page() {
}
const focusReviewDiff = (path: string) => {
openReviewPanel()
const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path])
setTree({ activeDiff: path, pendingDiff: path })
@ -1203,7 +1218,7 @@ export default function Page() {
if (!id) return
const wants = isDesktop()
? view().reviewPanel.opened() && (layout.fileTree.opened() || activeTab() === "review")
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (sync.data.session_diff[id] !== undefined) return
@ -1216,7 +1231,6 @@ export default function Page() {
createEffect(() => {
const dir = sdk.directory
if (!isDesktop()) return
if (!view().reviewPanel.opened()) return
if (!layout.fileTree.opened()) return
if (sync.status === "loading") return
@ -1533,10 +1547,10 @@ export default function Page() {
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 pt-2 md:pt-3": true,
"md:flex-none": view().reviewPanel.opened(),
"md:flex-none": desktopSidePanelOpen(),
}}
style={{
width: isDesktop() && view().reviewPanel.opened() ? `${layout.session.width()}px` : "100%",
width: sessionPanelWidth(),
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
>
@ -1663,7 +1677,7 @@ export default function Page() {
setPromptDockRef={(el) => (promptDock = el)}
/>
<Show when={isDesktop() && view().reviewPanel.opened()}>
<Show when={desktopReviewOpen()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
@ -1675,7 +1689,8 @@ export default function Page() {
</div>
<SessionSidePanel
open={isDesktop() && view().reviewPanel.opened()}
open={desktopSidePanelOpen()}
reviewOpen={desktopReviewOpen()}
language={language}
layout={layout}
command={command}

View File

@ -24,6 +24,7 @@ import { useSync } from "@/context/sync"
export function SessionSidePanel(props: {
open: boolean
reviewOpen: boolean
language: ReturnType<typeof useLanguage>
layout: ReturnType<typeof useLayout>
command: ReturnType<typeof useCommand>
@ -72,157 +73,166 @@ export function SessionSidePanel(props: {
<aside
id="review-panel"
aria-label={props.language.t("session.panel.reviewAndFiles")}
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
class="relative min-w-0 h-full border-l border-border-weak-base flex"
classList={{
"flex-1": props.reviewOpen,
"shrink-0": !props.reviewOpen,
}}
style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }}
>
<div class="flex-1 min-w-0 h-full">
<Show
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
fallback={
<DragDropProvider
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
onDragOver={props.onDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={props.activeTab()} onChange={props.openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
onCleanup(stop)
}}
>
<Show when={props.reviewTab}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5">
<div>{props.language.t("session.tab.review")}</div>
<Show when={props.hasReview}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{props.reviewCount}
</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={props.contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.tabs().close("context")}
aria-label={props.language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{props.language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={props.openedTabs()}>
<For each={props.openedTabs()}>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={props.language.t("command.file.open")}
keybind={props.command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() =>
props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />)
<Show when={props.reviewOpen}>
<div class="flex-1 min-w-0 h-full">
<Show
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
fallback={
<DragDropProvider
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
onDragOver={props.onDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={props.activeTab()} onChange={props.openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
onCleanup(stop)
}}
>
<Show when={props.reviewTab}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5">
<div>{props.language.t("session.tab.review")}</div>
<Show when={props.hasReview}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{props.reviewCount}
</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={props.contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.tabs().close("context")}
aria-label={props.language.t("common.closeTab")}
/>
</Tooltip>
}
aria-label={props.language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
hideCloseButton
onMiddleClick={() => props.tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{props.language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={props.openedTabs()}>
<For each={props.openedTabs()}>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={props.language.t("command.file.open")}
keybind={props.command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() =>
props.dialog.show(() => (
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
))
}
aria-label={props.language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<Show when={props.reviewTab}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{props.language.t("session.files.selectToOpen")}
</div>
</div>
</div>
<Show when={props.reviewTab}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
</Tabs.Content>
<Show when={props.contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "context"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab
messages={props.messages as never}
visibleUserMessages={props.visibleUserMessages as never}
view={props.view as never}
info={props.info as never}
/>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{props.language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={props.activeFileTab()} keyed>
{(tab) => (
<FileTabContent
tab={tab}
activeTab={props.activeTab}
tabs={props.tabs}
view={props.view}
handoffFiles={props.handoffFiles}
file={props.file}
comments={props.comments}
language={props.language}
codeComponent={props.codeComponent}
addCommentToContext={props.addCommentToContext}
/>
)}
</Show>
</Tabs>
<DragOverlay>
<Show when={props.activeDraggable()}>
{(tab) => {
const path = createMemo(() => props.file.pathFromTab(tab()))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
}
>
{props.reviewPanel()}
</Show>
</div>
<Show when={props.contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab
messages={props.messages as never}
visibleUserMessages={props.visibleUserMessages as never}
view={props.view as never}
info={props.info as never}
/>
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={props.activeFileTab()} keyed>
{(tab) => (
<FileTabContent
tab={tab}
activeTab={props.activeTab}
tabs={props.tabs}
view={props.view}
handoffFiles={props.handoffFiles}
file={props.file}
comments={props.comments}
language={props.language}
codeComponent={props.codeComponent}
addCommentToContext={props.addCommentToContext}
/>
)}
</Show>
</Tabs>
<DragOverlay>
<Show when={props.activeDraggable()}>
{(tab) => {
const path = createMemo(() => props.file.pathFromTab(tab()))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
}
>
{props.reviewPanel()}
</Show>
</div>
</Show>
<Show when={props.layout.fileTree.opened()}>
<div
@ -230,7 +240,10 @@ export function SessionSidePanel(props: {
class="relative shrink-0 h-full"
style={{ width: `${props.layout.fileTree.width()}px` }}
>
<div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weak-base": props.reviewOpen }}
>
<Tabs
variant="pill"
value={props.fileTreeTab()}

View File

@ -139,11 +139,8 @@ export const useSessionCommands = (input: {
title: input.language.t("command.fileTree.toggle"),
description: "",
category: input.language.t("command.category.view"),
onSelect: () => {
const opening = !input.layout.fileTree.opened()
if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open()
input.layout.fileTree.toggle()
},
keybind: "mod+\\",
onSelect: () => input.layout.fileTree.toggle(),
},
{
id: "terminal.new",

View File

@ -29,6 +29,7 @@ import {
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@ -986,7 +987,7 @@ export namespace ACP {
type: "image",
mimeType: effectiveMime,
data: base64Data,
uri: `file://${filename}`,
uri: pathToFileURL(filename).href,
},
},
})
@ -996,13 +997,14 @@ export namespace ACP {
} else {
// Non-image: text types get decoded, binary types stay as blob
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
const fileUri = pathToFileURL(filename).href
const resource = isText
? {
uri: `file://${filename}`,
uri: fileUri,
mimeType: effectiveMime,
text: Buffer.from(base64Data, "base64").toString("utf-8"),
}
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
: { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
await this.connection
.sessionUpdate({
@ -1544,7 +1546,7 @@ export namespace ACP {
const name = path.split("/").pop() || path
return {
type: "file",
url: `file://${path}`,
url: pathToFileURL(path).href,
filename: name,
mime: "text/plain",
}

View File

@ -1,5 +1,6 @@
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "bun"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
@ -314,7 +315,7 @@ export const RunCommand = cmd({
files.push({
type: "file",
url: `file://${resolvedPath}`,
url: pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})

View File

@ -1,4 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
@ -19,7 +20,7 @@ export function DialogStatus() {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
if (value.startsWith("file://")) {
const path = value.substring("file://".length)
const path = fileURLToPath(value)
const parts = path.split("/")
const filename = parts.pop() || path
if (!filename.includes(".")) return { name: filename }

View File

@ -1,4 +1,5 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
@ -246,17 +247,17 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
let url = `file://${process.cwd()}/${item}`
const fullPath = `${process.cwd()}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
const urlObj = new URL(url)
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
url = urlObj.toString()
}
const url = urlObj.href
const isDir = item.endsWith("/")
return {

View File

@ -33,6 +33,8 @@ import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const log = Log.create({ service: "config" })
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
@ -653,7 +655,7 @@ export namespace Config {
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
model: ModelId.optional(),
subtask: z.boolean().optional(),
})
export type Command = z.infer<typeof Command>
@ -669,7 +671,7 @@ export namespace Config {
export const Agent = z
.object({
model: z.string().optional(),
model: ModelId.optional(),
variant: z
.string()
.optional()
@ -1040,11 +1042,10 @@ export namespace Config {
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: z
.string()
.describe("Small model to use for tasks like title generation in the format of provider/model")
.optional(),
model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ModelId.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
default_agent: z
.string()
.optional()

View File

@ -3,7 +3,7 @@ import { Bus } from "@/bus"
import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { pathToFileURL } from "url"
import { pathToFileURL, fileURLToPath } from "url"
import { LSPServer } from "./server"
import z from "zod"
import { Config } from "../config/config"
@ -369,7 +369,7 @@ export namespace LSP {
}
export async function documentSymbol(uri: string) {
const file = new URL(uri).pathname
const file = fileURLToPath(uri)
return run(file, (client) =>
client.connection
.sendRequest("textDocument/documentSymbol", {

View File

@ -539,7 +539,7 @@ export const SessionRoutes = lazy(() =>
},
auto: body.auto,
})
await SessionPrompt.loop(sessionID)
await SessionPrompt.loop({ sessionID })
return c.json(true)
},
)

View File

@ -108,6 +108,7 @@ export namespace SessionCompaction {
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,

View File

@ -387,6 +387,7 @@ export namespace MessageV2 {
write: z.number(),
}),
}),
variant: z.string().optional(),
finish: z.string().optional(),
}).meta({
ref: "AssistantMessage",

View File

@ -32,7 +32,7 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $, fileURLToPath } from "bun"
import { $, fileURLToPath, pathToFileURL } from "bun"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
@ -174,7 +174,7 @@ export namespace SessionPrompt {
return message
}
return loop(input.sessionID)
return loop({ sessionID: input.sessionID })
})
export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
@ -210,7 +210,7 @@ export namespace SessionPrompt {
if (stats.isDirectory()) {
parts.push({
type: "file",
url: `file://${filepath}`,
url: pathToFileURL(filepath).href,
filename: name,
mime: "application/x-directory",
})
@ -219,7 +219,7 @@ export namespace SessionPrompt {
parts.push({
type: "file",
url: `file://${filepath}`,
url: pathToFileURL(filepath).href,
filename: name,
mime: "text/plain",
})
@ -239,6 +239,13 @@ export namespace SessionPrompt {
return controller.signal
}
function resume(sessionID: string) {
const s = state()
if (!s[sessionID]) return
return s[sessionID].abort.signal
}
export function cancel(sessionID: string) {
log.info("cancel", { sessionID })
const s = state()
@ -253,8 +260,14 @@ export namespace SessionPrompt {
return
}
export const loop = fn(Identifier.schema("session"), async (sessionID) => {
const abort = start(sessionID)
export const LoopInput = z.object({
sessionID: Identifier.schema("session"),
resume_existing: z.boolean().optional(),
})
export const loop = fn(LoopInput, async (input) => {
const { sessionID, resume_existing } = input
const abort = resume_existing ? resume(sessionID) : start(sessionID)
if (!abort) {
return new Promise<MessageV2.WithParts>((resolve, reject) => {
const callbacks = state()[sessionID].callbacks
@ -323,6 +336,7 @@ export namespace SessionPrompt {
sessionID,
mode: task.agent,
agent: task.agent,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@ -526,6 +540,7 @@ export namespace SessionPrompt {
role: "assistant",
mode: agent.name,
agent: agent.name,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@ -1366,7 +1381,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!abort) {
throw new Session.BusyError(input.sessionID)
}
using _ = defer(() => cancel(input.sessionID))
using _ = defer(() => {
// If no queued callbacks, cancel (the default)
const callbacks = state()[input.sessionID]?.callbacks ?? []
if (callbacks.length === 0) {
cancel(input.sessionID)
} else {
// Otherwise, trigger the session loop to process queued items
loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => {
log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error })
})
}
})
const session = await Session.get(input.sessionID)
if (session.revert) {

View File

@ -411,8 +411,13 @@ export namespace Worktree {
if (key === directory) return item
}
})()
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
const directoryExists = await exists(directory)
if (directoryExists) {
await fs.rm(directory, { recursive: true, force: true })
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)

View File

@ -0,0 +1,56 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
// Verify the URL is properly encoded (# should be %23)
expect(fileParts[0].url).toContain("%23")
// Verify the URL can be correctly converted back to a file path
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
// Verify the file content was read correctly
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})

View File

@ -0,0 +1,60 @@
import { describe, test, expect } from "bun:test"
import { Discovery } from "../../src/skill/discovery"
import path from "path"
const CLOUDFLARE_SKILLS_URL = "https://developers.cloudflare.com/.well-known/skills/"
describe("Discovery.pull", () => {
test("downloads skills from cloudflare url", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(Discovery.dir())
const md = path.join(dir, "SKILL.md")
expect(await Bun.file(md).exists()).toBe(true)
}
}, 30_000)
test("url without trailing slash works", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(await Bun.file(md).exists()).toBe(true)
}
}, 30_000)
test("returns empty array for invalid url", async () => {
const dirs = await Discovery.pull("https://example.invalid/.well-known/skills/")
expect(dirs).toEqual([])
})
test("returns empty array for non-json response", async () => {
const dirs = await Discovery.pull("https://example.com/")
expect(dirs).toEqual([])
})
test("downloads reference files alongside SKILL.md", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk"))
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(await Bun.file(path.join(agentsSdk, "SKILL.md")).exists()).toBe(true)
// agents-sdk has reference files per the index
const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))
expect(refDir.length).toBeGreaterThan(0)
}
}, 30_000)
test("caches downloaded files on second pull", async () => {
// first pull to populate cache
const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
// second pull should return same results from cache
const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
}, 60_000)
})

View File

@ -1,4 +1,5 @@
import { createOpencodeClient, createOpencodeServer } from "@opencode-ai/sdk"
import { pathToFileURL } from "bun"
const server = await createOpencodeServer()
const client = createOpencodeClient({ baseUrl: server.url })
@ -17,7 +18,7 @@ for await (const file of input) {
{
type: "file",
mime: "text/plain",
url: `file://${file}`,
url: pathToFileURL(file).href,
},
{
type: "text",
@ -41,7 +42,7 @@ await Promise.all(
{
type: "file",
mime: "text/plain",
url: `file://${file}`,
url: pathToFileURL(file).href,
},
{
type: "text",

View File

@ -197,6 +197,7 @@ export type AssistantMessage = {
write: number
}
}
variant?: string
finish?: string
}

View File

@ -6392,6 +6392,9 @@
},
"required": ["input", "output", "reasoning", "cache"]
},
"variant": {
"type": "string"
},
"finish": {
"type": "string"
}

View File

@ -29,15 +29,16 @@ opencode [project]
#### Flags
| Flag | Short | Description |
| ------------ | ----- | ------------------------------------------ |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--prompt` | | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--port` | | Port to listen on |
| `--hostname` | | Hostname to listen on |
| Flag | Short | Description |
| ------------ | ----- | ----------------------------------------------------------------------- |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) |
| `--prompt` | | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--port` | | Port to listen on |
| `--hostname` | | Hostname to listen on |
---
@ -334,19 +335,20 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript"
#### Flags
| Flag | Short | Description |
| ------------ | ----- | ------------------------------------------------------------------ |
| `--command` | | The command to run, use message for args |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--file` | `-f` | File(s) to attach to message |
| `--format` | | Format: default (formatted) or json (raw JSON events) |
| `--title` | | Title for the session (uses truncated prompt if no value provided) |
| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) |
| `--port` | | Port for the local server (defaults to random port) |
| Flag | Short | Description |
| ------------ | ----- | ----------------------------------------------------------------------- |
| `--command` | | The command to run, use message for args |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) |
| `--share` | | Share the session |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--file` | `-f` | File(s) to attach to message |
| `--format` | | Format: default (formatted) or json (raw JSON events) |
| `--title` | | Title for the session (uses truncated prompt if no value provided) |
| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) |
| `--port` | | Port for the local server (defaults to random port) |
---

View File

@ -0,0 +1,16 @@
diff --git a/dist/vendors/convert.js b/dist/vendors/convert.js
index 0d615eebfd7cd646937ec1b29f8332db73586ec1..7b146f903c07a9377d676753691cba67781879be 100644
--- a/dist/vendors/convert.js
+++ b/dist/vendors/convert.js
@@ -74,7 +74,10 @@ function convertToOpenAPISchema(jsonSchema, context) {
$ref: `#/components/schemas/${id}`
};
} else if (_jsonSchema.$ref) {
- const { $ref, $defs } = _jsonSchema;
+ const { $ref, $defs, ...rest } = _jsonSchema;
+ if ($ref.includes("://")) {
+ return Object.keys(rest).length > 0 ? rest : { type: "string" };
+ }
const ref = $ref.split("/").pop();
context.components.schemas = {
...context.components.schemas,

View File

@ -1,6 +1,7 @@
#!/usr/bin/env bun
import path from "path"
import { pathToFileURL } from "bun"
import { createOpencode } from "@opencode-ai/sdk"
import { parseArgs } from "util"
@ -49,7 +50,7 @@ Examples:
}
parts.push({
type: "file",
url: `file://${resolved}`,
url: pathToFileURL(resolved).href,
filename: path.basename(resolved),
mime: "text/plain",
})