Merge branch 'dev' into sqlite2

This commit is contained in:
Dax Raad 2026-01-25 20:18:36 -05:00
commit 0c1c07467e
176 changed files with 5510 additions and 1726 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.34",
"version": "1.1.36",
"description": "",
"type": "module",
"exports": {

View file

@ -45,7 +45,6 @@ async function waitForHealth(url: string) {
const appDir = process.cwd()
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json")
const extraArgs = (() => {
const args = process.argv.slice(2)
@ -59,8 +58,6 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const serverEnv = {
...process.env,
MODELS_DEV_API_JSON: modelsJson,
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_DISABLE_SHARE: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",

View file

@ -35,9 +35,10 @@ export const DialogSelectModelUnpaid: Component = () => {
return (
<Dialog title={language.t("dialog.model.select.title")}>
<div class="flex flex-col gap-3 px-2.5">
<div class="flex flex-col gap-3 px-2.5 flex-1 min-h-0">
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
ref={(ref) => (listRef = ref)}
items={local.model.list}
current={local.model.current()}

View file

@ -1,5 +1,6 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createMemo, createSignal, JSX, Show, ValidComponent } from "solid-js"
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
@ -92,26 +93,118 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
triggerAs?: T
triggerProps?: ComponentProps<T>
}) {
const [open, setOpen] = createSignal(false)
const [store, setStore] = createStore<{
open: boolean
dismiss: "escape" | "outside" | null
trigger?: HTMLElement
content?: HTMLElement
}>({
open: false,
dismiss: null,
trigger: undefined,
content: undefined,
})
const dialog = useDialog()
const handleManage = () => {
setOpen(false)
setStore("open", false)
dialog.show(() => <DialogManageModels />)
}
const language = useLanguage()
createEffect(() => {
if (!store.open) return
const inside = (node: Node | null | undefined) => {
if (!node) return false
const el = store.content
if (el && el.contains(node)) return true
const anchor = store.trigger
if (anchor && anchor.contains(node)) return true
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
const onFocusIn = (event: FocusEvent) => {
if (!store.content) return
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("pointerdown", onPointerDown, true)
window.addEventListener("focusin", onFocusIn, true)
onCleanup(() => {
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("pointerdown", onPointerDown, true)
window.removeEventListener("focusin", onFocusIn, true)
})
})
return (
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...(props.triggerProps as any)}>
<Kobalte
open={store.open}
onOpenChange={(next) => {
if (next) setStore("dismiss", null)
setStore("open", next)
}}
modal={false}
placement="top-start"
gutter={8}
>
<Kobalte.Trigger
ref={(el) => setStore("trigger", el)}
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
<Kobalte.Content
ref={(el) => setStore("content", el)}
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onFocusOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onCloseAutoFocus={(event) => {
if (store.dismiss === "outside") event.preventDefault()
setStore("dismiss", null)
}}
>
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList
provider={props.provider}
onSelect={() => setOpen(false)}
onSelect={() => setStore("open", false)}
class="p-1"
action={
<IconButton

View file

@ -1,23 +1,48 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useGlobalSDK } from "@/context/global-sdk"
type ServerStatus = { healthy: boolean; version?: string }
async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
interface AddRowProps {
value: string
placeholder: string
adding: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
interface EditRowProps {
value: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
const sdk = createOpencodeClient({
baseUrl: url,
fetch,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
@ -26,21 +51,158 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis
.catch(() => ({ healthy: false }))
}
function AddRow(props: AddRowProps) {
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<div
classList={{
"size-1.5 rounded-full absolute left-3 z-10 pointer-events-none": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
style={{ top: "50%", transform: "translateY(-50%)" }}
ref={(el) => {
// Position relative to input-wrapper
requestAnimationFrame(() => {
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
if (wrapper instanceof HTMLElement) {
wrapper.style.position = "relative"
wrapper.appendChild(el)
}
})
}}
/>
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.adding}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
class="pl-7"
/>
</div>
</div>
)
}
function EditRow(props: EditRowProps) {
return (
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<div class="flex-1 min-w-0">
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
/>
</div>
</div>
)
}
export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [store, setStore] = createStore({
url: "",
adding: false,
error: "",
status: {} as Record<string, ServerStatus | undefined>,
addServer: {
url: "",
adding: false,
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
error: "",
busy: false,
status: undefined as boolean | undefined,
},
})
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
},
{ initialValue: null },
)
const isDesktop = platform.platform === "desktop"
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkHealth(normalized, platform)
setStatus(result.healthy)
}
const resetAdd = () => {
setStore("addServer", {
url: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
error: "",
status: undefined,
busy: false,
})
}
const replaceServer = (original: string, next: string) => {
const active = server.url
const nextActive = active === original ? next : active
server.add(next)
if (nextActive) server.setActive(nextActive)
server.remove(original)
}
const items = createMemo(() => {
const current = server.url
const list = server.list
@ -74,7 +236,7 @@ export function DialogSelectServer() {
const results: Record<string, ServerStatus> = {}
await Promise.all(
items().map(async (url) => {
results[url] = await checkHealth(url, platform.fetch)
results[url] = await checkHealth(url, platform)
}),
)
setStore("status", reconcile(results))
@ -87,7 +249,7 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval))
})
function select(value: string, persist?: boolean) {
async function select(value: string, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return
dialog.close()
if (persist) {
@ -99,24 +261,101 @@ export function DialogSelectServer() {
navigate("/")
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const value = normalizeServerUrl(store.url)
if (!value) return
const handleAddChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, (next) => setStore("addServer", { status: next }))
}
setStore("adding", true)
setStore("error", "")
const scrollListToBottom = () => {
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
if (!scroll) return
requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight
})
}
const result = await checkHealth(value, platform.fetch)
setStore("adding", false)
const handleEditChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { value, error: "" })
void previewStatus(value, (next) => setStore("editServer", { status: next }))
}
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("url", "")
select(value, true)
setStore("addServer", { adding: true, error: "" })
const result = await checkHealth(normalized, platform)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(normalized, true)
}
async function handleEdit(original: string, value: string) {
if (store.editServer.busy) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
if (normalized === original) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const result = await checkHealth(normalized, platform)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
replaceServer(original, normalized)
resetEdit()
}
const handleAddKey = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleAdd(store.addServer.url)
}
const blurAdd = () => {
if (!store.addServer.url.trim()) {
resetAdd()
return
}
handleAdd(store.addServer.url)
}
const handleEditKey = (event: KeyboardEvent, original: string) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
resetEdit()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleEdit(original, store.editServer.value)
}
async function handleRemove(url: string) {
@ -124,125 +363,203 @@ export function DialogSelectServer() {
}
return (
<Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}>
<div class="flex flex-col gap-4 pb-4">
<Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2">
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }}
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
current={current()}
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
}
>
{(i) => (
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
{(i) => {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
setTruncated(nameTruncated || versionTruncated)
}
createEffect(() => {
check()
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
})
const tooltipValue = () => {
const name = serverDisplayName(i)
const version = store.status[i]?.version
return (
<span class="flex items-center gap-2">
<span>{name}</span>
<Show when={version}>
<span class="text-text-invert-base">{version}</span>
</Show>
</span>
)
}
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<div
class="flex items-center gap-3 px-4 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span ref={nameRef} class="truncate">
{serverDisplayName(i)}
</span>
<Show when={store.status[i]?.version}>
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
{store.status[i]?.version}
</span>
</Show>
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
</div>
</Tooltip>
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={isDesktop && defaultUrl() !== i}>
<DropdownMenu.Item
onSelect={async () => {
await platform.setDefaultServerUrl?.(i)
defaultUrlActions.mutate(i)
}}
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={isDesktop && defaultUrl() === i}>
<DropdownMenu.Item
onSelect={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.mutate(null)
}}
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</div>
<Show when={current() !== i && server.list.includes(i)}>
<IconButton
icon="circle-x"
variant="ghost"
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
aria-label={language.t("dialog.server.action.remove")}
onClick={(e) => {
e.stopPropagation()
handleRemove(i)
}}
/>
</Show>
</div>
)}
)
}}
</List>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.add.title")}</h3>
</div>
<form onSubmit={handleSubmit}>
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0 h-auto">
<TextField
type="text"
label={language.t("dialog.server.add.url")}
hideLabel
placeholder={language.t("dialog.server.add.placeholder")}
value={store.url}
onChange={(v) => {
setStore("url", v)
setStore("error", "")
}}
validationState={store.error ? "invalid" : "valid"}
error={store.error}
/>
</div>
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
{store.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
</div>
</form>
<div class="px-5 pb-5">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
</div>
<Show when={isDesktop}>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3>
<p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p>
</div>
<div class="flex items-center gap-2 px-3 py-2">
<Show
when={defaultUrl()}
fallback={
<Show
when={server.url}
fallback={
<span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span>
}
>
<Button
variant="secondary"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(server.url)
defaultUrlActions.refetch(server.url)
}}
>
{language.t("dialog.server.default.set")}
</Button>
</Show>
}
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
</div>
<Button
variant="ghost"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.refetch()
}}
>
{language.t("dialog.server.default.clear")}
</Button>
</Show>
</div>
</div>
</Show>
</div>
</Dialog>
)

View file

@ -39,7 +39,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
@ -123,6 +123,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
const layout = useLayout()
const comments = useComments()
const params = useParams()
@ -136,6 +137,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
const mirror = { input: false }
const scrollCursorIntoView = () => {
const container = scrollRef
const selection = window.getSelection()
@ -170,6 +173,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const commentInReview = (path: string) => {
const sessionID = params.id
if (!sessionID) return false
const diffs = sync.data.session_diff[sessionID]
if (!diffs) return false
return diffs.some((diff) => diff.file === path)
}
const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
if (!item.commentID) return
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
view().reviewPanel.open()
if (item.commentOrigin === "review") {
tabs().open("review")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
if (item.commentOrigin !== "file" && commentInReview(item.path)) {
tabs().open("review")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
const tab = files.tab(item.path)
tabs().open(tab)
files.load(item.path)
requestAnimationFrame(() => comments.setFocus(focus))
}
const recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
@ -619,6 +656,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.current(),
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
@ -733,6 +789,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
if (prompt.dirty()) {
mirror.input = true
prompt.set(DEFAULT_PROMPT, 0)
}
queueScroll()
@ -763,6 +820,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
mirror.input = true
prompt.set([...rawParts, ...images], cursorPosition)
queueScroll()
}
@ -1481,6 +1539,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
@ -1547,6 +1606,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
@ -1661,7 +1721,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.dragging,
[props.class ?? ""]: !!props.class,
}}
@ -1675,54 +1735,78 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
<Show when={prompt.context.items().length > 0}>
<div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={prompt.context.items()}>
{(item) => {
const active = () => {
const a = comments.active()
return !!item.commentID && item.commentID === a?.id && item.path === a?.file
}
return (
<div
classList={{
"shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
"cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
}}
onClick={() => {
if (!item.commentID) return
comments.setFocus({ file: item.path, id: item.commentID })
view().reviewPanel.open()
tabs().open("review")
}}
<Tooltip
value={
<span class="flex max-w-[300px]">
<span
class="text-text-invert-base truncate min-w-0"
style={{ direction: "rtl", "text-align": "left", "unicode-bidi": "plaintext" }}
>
{getDirectory(item.path)}
</span>
<span class="shrink-0">{getFilename(item.path)}</span>
</span>
}
placement="top"
openDelay={2000}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(),
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
active(),
"bg-background-stronger": !active(),
}}
onClick={() => {
openComment(item)
}}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div
class="flex items-center text-11-regular min-w-0"
style={{ "font-weight": "var(--font-weight-medium)" }}
>
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
aria-label={language.t("prompt.context.removeFile")}
/>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-5 w-5"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
aria-label={language.t("prompt.context.removeFile")}
/>
<Show when={item.comment}>
{(comment) => (
<div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>
)}
</Show>
</div>
<Show when={item.comment}>
{(comment) => <div class="text-11-regular text-text-strong">{comment()}</div>}
</Show>
</div>
</Tooltip>
)
}}
</For>
@ -1778,7 +1862,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-label={
store.mode === "shell"
? language.t("prompt.placeholder.shell")
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
: commentCount() > 1
? "Summarize comments…"
: commentCount() === 1
? "Summarize comment…"
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
}
contenteditable="true"
onInput={handleInput}
@ -1788,17 +1876,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell"
? language.t("prompt.placeholder.shell")
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
: commentCount() > 1
? "Summarize comments…"
: commentCount() === 1
? "Summarize comment…"
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
</div>
</Show>
</div>
@ -1905,7 +1997,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match>
</Switch>
</div>
<div class="flex items-center gap-3 absolute right-2 bottom-2">
<div class="flex items-center gap-3 absolute right-3 bottom-3">
<input
ref={fileInputRef}
type="file"

View file

@ -4,6 +4,7 @@ import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
import { findLast } from "@opencode-ai/util/array"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
@ -25,18 +26,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const view = createMemo(() => layout.view(sessionKey))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
style: "currency",
currency: "USD",
}),
)
const cost = createMemo(() => {
const locale = language.locale()
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
}).format(total)
return usd().format(total)
})
const context = createMemo(() => {
const locale = language.locale()
const last = messages().findLast((x) => {
const last = findLast(messages(), (x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
@ -84,9 +89,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
</div>
<Show when={variant() === "button"}>
<div class="text-11-regular text-text-invert-base mt-1">{language.t("context.usage.clickToView")}</div>
</Show>
</div>
)

View file

@ -1,42 +0,0 @@
import { createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export function SessionLspIndicator() {
const sync = useSync()
const language = useLanguage()
const lspStats = createMemo(() => {
const lsp = sync.data.lsp ?? []
const connected = lsp.filter((s) => s.status === "connected").length
const hasError = lsp.some((s) => s.status === "error")
const total = lsp.length
return { connected, hasError, total }
})
const tooltipContent = createMemo(() => {
const lsp = sync.data.lsp ?? []
if (lsp.length === 0) return language.t("lsp.tooltip.none")
return lsp.map((s) => s.name).join(", ")
})
return (
<Show when={lspStats().total > 0}>
<Tooltip placement="top" value={tooltipContent()}>
<div class="flex items-center gap-1 px-2 cursor-default select-none">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-critical-base": lspStats().hasError,
"bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
}}
/>
<span class="text-12-regular text-text-weak">
{language.t("lsp.label.connected", { count: lspStats().connected })}
</span>
</div>
</Tooltip>
</Show>
)
}

View file

@ -1,34 +0,0 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
export function SessionMcpIndicator() {
const sync = useSync()
const dialog = useDialog()
const mcpStats = createMemo(() => {
const mcp = sync.data.mcp ?? {}
const entries = Object.entries(mcp)
const enabled = entries.filter(([, status]) => status.status === "connected").length
const failed = entries.some(([, status]) => status.status === "failed")
const total = entries.length
return { enabled, failed, total }
})
return (
<Show when={mcpStats().total > 0}>
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-critical-base": mcpStats().failed,
"bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
}}
/>
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
</Button>
</Show>
)
}

View file

@ -5,6 +5,7 @@ import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@ -25,8 +26,16 @@ export function SessionContextTab(props: SessionContextTabProps) {
const sync = useSync()
const language = useLanguage()
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
style: "currency",
currency: "USD",
}),
)
const ctx = createMemo(() => {
const last = props.messages().findLast((x) => {
const last = findLast(props.messages(), (x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
@ -61,12 +70,8 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const cost = createMemo(() => {
const locale = language.locale()
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
}).format(total)
return usd().format(total)
})
const counts = createMemo(() => {
@ -81,7 +86,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const systemPrompt = createMemo(() => {
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()

View file

@ -5,8 +5,6 @@ import { useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
// import { useServer } from "@/context/server"
// import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/context/platform"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
@ -20,14 +18,13 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { Keybind } from "@opencode-ai/ui/keybind"
import { StatusPopover } from "../status-popover"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
// const server = useServer()
// const dialog = useDialog()
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
@ -154,65 +151,104 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
{/* <div class="hidden md:flex items-center gap-1"> */}
{/* <Button */}
{/* size="small" */}
{/* variant="ghost" */}
{/* onClick={() => { */}
{/* dialog.show(() => <DialogSelectServer />) */}
{/* }} */}
{/* > */}
{/* <div */}
{/* classList={{ */}
{/* "size-1.5 rounded-full": true, */}
{/* "bg-icon-success-base": server.healthy() === true, */}
{/* "bg-icon-critical-base": server.healthy() === false, */}
{/* "bg-border-weak-base": server.healthy() === undefined, */}
{/* }} */}
{/* /> */}
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
{/* </Button> */}
{/* <SessionLspIndicator /> */}
{/* <SessionMcpIndicator /> */}
{/* </div> */}
<div class="flex items-center gap-1">
<div class="hidden md:block shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
<StatusPopover />
<Show when={showShare()}>
<div class="flex items-center">
<Popover
title={language.t("session.share.popover.title")}
description={
shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
gutter={6}
placement="bottom-end"
shift={-64}
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
triggerAs={Button}
triggerProps={{
variant: "secondary",
class: "rounded-sm w-[60px] h-[24px]",
classList: { "rounded-r-none": shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
tabIndex={showReview() ? 0 : -1}
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
</div>
}
>
<div class="flex flex-col gap-2">
<TextField value={shareUrl() ?? ""} readOnly copyable tabIndex={-1} class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip
value={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
placement="top"
gutter={8}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<IconButton
icon={state.copied ? "check" : "link"}
variant="secondary"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
aria-label={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
/>
</Tooltip>
</Show>
</div>
</Show>
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
class="hidden md:block shrink-0"
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
@ -244,96 +280,37 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<Show when={showShare()}>
<div class="flex items-center">
<Popover
title={language.t("session.share.popover.title")}
description={
shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
triggerAs={Button}
triggerProps={{
variant: "secondary",
classList: { "rounded-r-none": shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
<div class="hidden md:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
tabIndex={showReview() ? 0 : -1}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
</div>
}
>
<div class="flex flex-col gap-2 w-72">
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
<Tooltip
value={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
placement="top"
gutter={8}
>
<IconButton
icon={state.copied ? "check" : "copy"}
variant="secondary"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
aria-label={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
</Tooltip>
</Show>
</div>
</Show>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</Portal>
)}

View file

@ -4,7 +4,6 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
@ -60,18 +59,7 @@ export function NewSessionView(props: NewSessionViewProps) {
</div>
<div class="flex justify-center items-center gap-1">
<Icon name="branch" size="small" />
<Select
options={options()}
current={current()}
value={(x) => x}
label={label}
onSelect={(value) => {
props.onWorktreeChange(value ?? MAIN_WORKTREE)
}}
size="normal"
variant="ghost"
class="text-12-medium"
/>
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
</div>
<Show when={sync.project}>
{(project) => (

View file

@ -7,6 +7,25 @@ import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const playDemoSound = (src: string) => {
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src)
}, 100)
}
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
@ -36,6 +55,7 @@ export const SettingsGeneral: Component = () => {
{ value: "hack", label: "font.option.hack" },
{ value: "inconsolata", label: "font.option.inconsolata" },
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
{ value: "iosevka", label: "font.option.iosevka" },
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
{ value: "roboto-mono", label: "font.option.robotoMono" },
@ -210,12 +230,12 @@ export const SettingsGeneral: Component = () => {
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setAgent(option.id)
playSound(option.src)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
@ -234,12 +254,12 @@ export const SettingsGeneral: Component = () => {
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setPermissions(option.id)
playSound(option.src)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
@ -258,12 +278,12 @@ export const SettingsGeneral: Component = () => {
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setErrors(option.id)
playSound(option.src)
playDemoSound(option.src)
}}
variant="secondary"
size="small"

View file

@ -0,0 +1,405 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { DialogSelectServer } from "./dialog-select-server"
type ServerStatus = { healthy: boolean; version?: string }
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
.catch(() => ({ healthy: false }))
}
export function StatusPopover() {
const sync = useSync()
const sdk = useSDK()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const [loading, setLoading] = createSignal<string | null>(null)
const [store, setStore] = createStore({
status: {} as Record<string, ServerStatus | undefined>,
})
const servers = createMemo(() => {
const current = server.url
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const sortedServers = createMemo(() => {
const list = servers()
if (!list.length) return list
const active = server.url
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerStatus) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerStatus> = {}
await Promise.all(
servers().map(async (url) => {
results[url] = await checkHealth(url, platform)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
servers()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const mcpItems = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => {
if (loading()) return
setLoading(name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
}
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
return serverHealthy && !anyMcpIssue
})
const serverCount = createMemo(() => sortedServers().length)
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
createEffect(() => {
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) {
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
return
}
if (result) setDefaultServerUrl(normalizeServerUrl(result))
})
return (
<Popover
triggerAs={Button}
triggerProps={{
variant: "ghost",
class:
"rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none data-[expanded]:bg-surface-raised-base-active",
style: { scale: 1 },
}}
trigger={
<div class="flex items-center gap-1.5">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<span class="text-12-regular text-text-strong">Status</span>
</div>
}
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={6}
placement="bottom-end"
shift={-136}
>
<div
class="flex items-center gap-1 w-[360px] rounded-xl"
style={{ "box-shadow": "var(--shadow-lg-border-base)" }}
>
<Tabs
aria-label="Server Configurations"
class="tabs"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
style={{
"background-color": "var(--background-strong)",
"border-radius": "12px",
overflow: "hidden",
}}
>
<Tabs.List
data-slot="tablist"
style={{
"background-color": "transparent",
"border-bottom": "none",
padding: "8px 16px 0",
gap: "16px",
height: "40px",
}}
>
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{serverCount() > 0 ? `${serverCount()} ` : ""}Servers
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}LSP
</Tabs.Trigger>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(url) => {
const isActive = () => url === server.url
const isDefault = () => url === defaultServerUrl()
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
onMount(() => {
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
setTruncated(nameTruncated || versionTruncated)
}
check()
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
})
const tooltipValue = () => {
const name = serverDisplayName(url)
const version = status()?.version
return (
<span class="flex items-center gap-2">
<span>{name}</span>
<Show when={version}>
<span class="text-text-invert-base">{version}</span>
</Show>
</span>
)
}
return (
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"opacity-50": isBlocked(),
"hover:bg-surface-raised-base-hover": !isBlocked(),
"cursor-not-allowed": isBlocked(),
}}
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
server.setActive(url)
navigate("/")
}}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status()?.healthy === true,
"bg-icon-critical-base": status()?.healthy === false,
"bg-border-weak-base": status() === undefined,
}}
/>
<span ref={nameRef} class="text-14-regular text-text-base truncate">
{serverDisplayName(url)}
</span>
<Show when={status()?.version}>
<span ref={versionRef} class="text-12-regular text-text-weak truncate">
{status()?.version}
</span>
</Show>
<Show when={isDefault()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
Default
</span>
</Show>
<div class="flex-1" />
<Show when={isActive()}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</button>
</Tooltip>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
Manage servers
</Button>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">No MCP servers configured</div>
}
>
<For each={mcpItems()}>
{(item) => {
const enabled = () => item.status === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => toggleMcp(item.name)}
disabled={loading() === item.name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "failed",
"bg-border-weak-base": item.status === "disabled",
"bg-icon-warning-base":
item.status === "needs_auth" || item.status === "needs_client_registration",
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={loading() === item.name}
onChange={() => toggleMcp(item.name)}
/>
</div>
</button>
)
}}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={lspItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
LSPs auto-detected from file types
</div>
}
>
<For each={lspItems()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "error",
}}
/>
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
Plugins configured in{" "}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">opencode.json</code>
</div>
}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
</Popover>
)
}

View file

@ -111,6 +111,8 @@ export const Terminal = (props: TerminalProps) => {
const mod = await import("ghostty-web")
ghostty = await mod.Ghostty.load()
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
@ -258,6 +260,8 @@ export const Terminal = (props: TerminalProps) => {
})
socket.addEventListener("error", (error) => {
if (disposed) return
if (once.value) return
once.value = true
console.error("WebSocket error:", error)
local.onConnectError?.(error)
})
@ -266,6 +270,8 @@ export const Terminal = (props: TerminalProps) => {
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) {
if (once.value) return
once.value = true
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
}
})

View file

@ -19,9 +19,6 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const reserve = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
const web = createMemo(() => platform.platform === "web")
const getWin = () => {
@ -81,7 +78,7 @@ export function Titlebar() {
classList={{
"flex items-center w-full min-w-0": true,
"pl-2": !mac(),
"pr-2": !windows(),
"pr-6": !windows(),
}}
onMouseDown={drag}
data-tauri-drag-region
@ -145,6 +142,7 @@ export function Titlebar() {
data-tauri-drag-region
/>
<Show when={windows()}>
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>

View file

@ -38,6 +38,7 @@ function createCommentSession(dir: string, id: string | undefined) {
)
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
const [active, setActive] = createSignal<CommentFocus | null>(null)
const list = (file: string) => store.comments[file] ?? []
@ -76,6 +77,9 @@ function createCommentSession(dir: string, id: string | undefined) {
focus: createMemo(() => focus()),
setFocus,
clearFocus: () => setFocus(null),
active: createMemo(() => active()),
setActive,
clearActive: () => setActive(null),
}
}
@ -135,6 +139,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
clearFocus: () => session().clearFocus(),
active: () => session().active(),
setActive: (active: CommentFocus | null) => session().setActive(active),
clearActive: () => session().clearActive(),
}
},
})

View file

@ -24,6 +24,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
type Queued = { directory: string; payload: Event }
let queue: Array<Queued | undefined> = []
let buffer: Array<Queued | undefined> = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
@ -41,10 +42,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
queue = []
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
if (events.length === 0) return
last = Date.now()
batch(() => {
@ -53,6 +57,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
@ -61,10 +67,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
}
const stop = () => {
flush()
}
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
@ -87,12 +89,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})()
.finally(stop)
.finally(flush)
.catch(() => undefined)
onCleanup(() => {
abort.abort()
stop()
flush()
})
const sdk = createOpencodeClient({

View file

@ -119,6 +119,16 @@ type ChildOptions = {
bootstrap?: boolean
}
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
all: input.all.map((provider) => ({
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
})),
}
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@ -129,6 +139,21 @@ function createGlobalSync() {
const metaCache = new Map<string, MetaCache>()
const iconCache = new Map<string, IconCache>()
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
sdkCache.set(directory, sdk)
return sdk
}
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
@ -183,7 +208,7 @@ function createGlobalSync() {
setProjectCache("value", projects.map(sanitizeProject))
})
createEffect(async () => {
createEffect(() => {
if (globalStore.reload !== "complete") return
if (bootstrapQueue.length) {
for (const directory of bootstrapQueue) {
@ -203,14 +228,16 @@ function createGlobalSync() {
function ensureChild(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const cache = runWithOwner(owner, () =>
const vcs = runWithOwner(owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!cache) throw new Error("Failed to create persisted cache")
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0]
const vcsReady = vcs[3]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
const meta = runWithOwner(owner, () =>
persisted(
@ -250,7 +277,7 @@ function createGlobalSync() {
question: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
vcs: vcsStore.value,
limit: 5,
message: {},
part: {},
@ -258,6 +285,13 @@ function createGlobalSync() {
children[directory] = child
createEffect(() => {
if (!vcsReady()) return
const cached = vcsStore.value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
createEffect(() => {
child[1]("projectMeta", meta[0].value)
})
@ -297,7 +331,6 @@ function createGlobalSync() {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
// Read the current limit at resolve-time so callers that bump the limit while
@ -348,38 +381,18 @@ function createGlobalSync() {
if (!cache) return
const meta = metaCache.get(directory)
if (!meta) return
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
const sdk = sdkFor(directory)
setStore("status", "loading")
createEffect(() => {
if (!cache.ready()) return
const cached = cache.store.value
if (!cached?.branch) return
setStore("vcs", (value) => value ?? cached)
})
// projectMeta is synced from persisted storage in ensureChild.
// vcs is seeded from persisted storage in ensureChild.
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () =>
sdk.provider.list().then((x) => {
const data = x.data!
setStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
})
setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
@ -432,10 +445,7 @@ function createGlobalSync() {
"permission",
sessionID,
reconcile(
permissions
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@ -464,10 +474,7 @@ function createGlobalSync() {
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@ -750,13 +757,9 @@ function createGlobalSync() {
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
sdkFor(directory)
.lsp.status()
.then((x) => setStore("lsp", x.data ?? []))
break
}
}
@ -796,16 +799,7 @@ function createGlobalSync() {
),
retry(() =>
globalSDK.client.provider.list().then((x) => {
const data = x.data!
setGlobalStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
})
setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>

View file

@ -209,6 +209,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
const colorRequested = new Map<string, AvatarColorKey>()
function pickAvailableColor(used: Set<string>): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
@ -267,17 +268,36 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return map
})
createEffect(() => {
const rootFor = (directory: string) => {
const map = roots()
if (map.size === 0) return
if (map.size === 0) return directory
const visited = new Set<string>()
const chain = [directory]
while (chain.length) {
const current = chain[chain.length - 1]
if (!current) return directory
const next = map.get(current)
if (!next) return current
if (visited.has(next)) return directory
visited.add(next)
chain.push(next)
}
return directory
}
createEffect(() => {
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
batch(() => {
for (const project of projects) {
const root = map.get(project.worktree)
if (!root) continue
const root = rootFor(project.worktree)
if (root === project.worktree) continue
server.projects.close(project.worktree)
@ -305,13 +325,21 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!globalSync.ready) return
if (globalSync.ready) {
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
globalSync.project.icon(project.worktree, project.icon?.override)
}
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
globalSync.project.icon(project.worktree, project.icon?.override)
}
})
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
for (const project of projects) {
if (project.icon?.color) colorRequested.delete(project.worktree)
}
const used = new Set<string>()
@ -322,18 +350,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
for (const project of projects) {
if (project.icon?.color) continue
const existing = colors[project.worktree]
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
if (!existing) {
used.add(color)
setColors(project.worktree, color)
setColors(worktree, color)
}
if (!project.id) continue
const requested = colorRequested.get(worktree)
if (requested === color) continue
colorRequested.set(worktree, color)
if (project.id === "global") {
globalSync.project.meta(project.worktree, { icon: { color } })
globalSync.project.meta(worktree, { icon: { color } })
continue
}
void globalSdk.client.project.update({ projectID: project.id, directory: project.worktree, icon: { color } })
void globalSdk.client.project
.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
}
})
@ -350,7 +389,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
const root = roots().get(directory) ?? directory
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
globalSync.project.loadSessions(root)
server.projects.open(root)
@ -384,7 +423,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("sidebar", "width", width)
},
workspaces(directory: string) {
return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)

View file

@ -126,7 +126,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey>
model: Record<string, ModelKey | undefined>
}>({
model: {},
})
@ -182,7 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const fallbackModel = createMemo(() => {
const fallbackModel = createMemo<ModelKey | undefined>(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
if (isModelValid({ providerID, modelID })) {
@ -199,16 +199,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const defaults = providers.default()
for (const p of providers.connected()) {
if (p.id in providers.default()) {
return {
providerID: p.id,
modelID: providers.default()[p.id],
}
const configured = defaults[p.id]
if (configured) {
const key = { providerID: p.id, modelID: configured }
if (isModelValid(key)) return key
}
const first = Object.values(p.models)[0]
if (!first) continue
const key = { providerID: p.id, modelID: first.id }
if (isModelValid(key)) return key
}
throw new Error("No default model found")
return undefined
})
const current = createMemo(() => {
@ -266,7 +271,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
const currentAgent = agent.current()
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) updateVisibility(model, "show")
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)

View file

@ -44,6 +44,7 @@ export type FileContextItem = {
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}

View file

@ -65,6 +65,7 @@ const monoFonts: Record<string, string> = {
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,

View file

@ -72,7 +72,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
@ -83,10 +82,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@ -146,10 +142,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
@ -291,7 +284,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))

View file

@ -13,7 +13,6 @@ export type LocalPTY = {
cols?: number
buffer?: string
scrollY?: number
error?: boolean
}
const WORKSPACE_KEY = "__workspace__"
@ -151,13 +150,18 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
...pty,
...clone.data,
})
if (active) {
setStore("active", clone.data.id)
}
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)

View file

@ -223,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
"dialog.mcp.empty": "لم يتم تكوين MCPs",
"dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات",
"dialog.plugins.empty": "الإضافات المكونة في opencode.json",
"mcp.status.connected": "متصل",
"mcp.status.failed": "فشل",
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
@ -242,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "تعذر الاتصال بالخادم",
"dialog.server.add.checking": "جارٍ التحقق...",
"dialog.server.add.button": "إضافة",
"dialog.server.add.button": "إضافة خادم",
"dialog.server.default.title": "الخادم الافتراضي",
"dialog.server.default.description":
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
@ -251,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "مسح",
"dialog.server.action.remove": "إزالة الخادم",
"dialog.server.menu.edit": "تعديل",
"dialog.server.menu.default": "تعيين كافتراضي",
"dialog.server.menu.defaultRemove": "إزالة الافتراضي",
"dialog.server.menu.delete": "حذف",
"dialog.server.current": "الخادم الحالي",
"dialog.server.status.default": "افتراضي",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة",
@ -294,20 +304,20 @@ export const dict = {
"context.usage.clickToView": "انقر لعرض السياق",
"context.usage.view": "عرض استخدام السياق",
"language.en": "الإنجليزية",
"language.zh": "الصينية (المبسطة)",
"language.zht": "الصينية (التقليدية)",
"language.ko": "الكورية",
"language.de": "الألمانية",
"language.es": "الإسبانية",
"language.fr": "الفرنسية",
"language.ja": "اليابانية",
"language.da": "الدانماركية",
"language.ru": "الروسية",
"language.pl": "البولندية",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "النرويجية",
"language.br": "البرتغالية (البرازيل)",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "لغة",
"toast.language.description": "تم التبديل إلى {{language}}",

View file

@ -298,19 +298,19 @@ export const dict = {
"context.usage.clickToView": "Clique para ver o contexto",
"context.usage.view": "Ver uso do contexto",
"language.en": "Inglês",
"language.zh": "Chinês (Simplificado)",
"language.zht": "Chinês (Tradicional)",
"language.ko": "Coreano",
"language.de": "Alemão",
"language.es": "Espanhol",
"language.fr": "Frans",
"language.ja": "Japonês",
"language.da": "Dinamarquês",
"language.ru": "Russo",
"language.pl": "Polonês",
"language.ar": "Árabe",
"language.no": "Norueguês",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "Idioma",

View file

@ -205,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} af {{total}} aktiveret",
"dialog.mcp.empty": "Ingen MCP'er konfigureret",
"dialog.lsp.empty": "LSP'er registreret automatisk fra filtyper",
"dialog.plugins.empty": "Plugins konfigureret i opencode.json",
"mcp.status.connected": "forbundet",
"mcp.status.failed": "mislykkedes",
"mcp.status.needs_auth": "kræver godkendelse",
@ -224,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Kunne ikke forbinde til server",
"dialog.server.add.checking": "Tjekker...",
"dialog.server.add.button": "Tilføj",
"dialog.server.add.button": "Tilføj server",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
@ -233,6 +236,13 @@ export const dict = {
"dialog.server.default.clear": "Ryd",
"dialog.server.action.remove": "Fjern server",
"dialog.server.menu.edit": "Rediger",
"dialog.server.menu.default": "Sæt som standard",
"dialog.server.menu.defaultRemove": "Fjern som standard",
"dialog.server.menu.delete": "Slet",
"dialog.server.current": "Nuværende server",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Rediger projekt",
"dialog.project.edit.name": "Navn",
"dialog.project.edit.icon": "Ikon",
@ -277,20 +287,20 @@ export const dict = {
"context.usage.clickToView": "Klik for at se kontekst",
"context.usage.view": "Se kontekstforbrug",
"language.en": "Engelsk",
"language.zh": "Kinesisk (forenklet)",
"language.zht": "Kinesisk (traditionelt)",
"language.ko": "Koreansk",
"language.de": "Tysk",
"language.es": "Spansk",
"language.fr": "Fransk",
"language.ja": "Japansk",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ru": "Russisk",
"language.pl": "Polsk",
"language.ar": "Arabisk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Portugisisk (Brasilien)",
"language.br": "Português (Brasil)",
"toast.language.title": "Sprog",
"toast.language.description": "Skiftede til {{language}}",

View file

@ -210,6 +210,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} von {{total}} aktiviert",
"dialog.mcp.empty": "Keine MCPs konfiguriert",
"dialog.lsp.empty": "LSPs automatisch nach Dateityp erkannt",
"dialog.plugins.empty": "In opencode.json konfigurierte Plugins",
"mcp.status.connected": "verbunden",
"mcp.status.failed": "fehlgeschlagen",
"mcp.status.needs_auth": "benötigt Authentifizierung",
@ -229,7 +232,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
"dialog.server.add.checking": "Prüfen...",
"dialog.server.add.button": "Hinzufügen",
"dialog.server.add.button": "Server hinzufügen",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
@ -238,6 +241,13 @@ export const dict = {
"dialog.server.default.clear": "Löschen",
"dialog.server.action.remove": "Server entfernen",
"dialog.server.menu.edit": "Bearbeiten",
"dialog.server.menu.default": "Als Standard festlegen",
"dialog.server.menu.defaultRemove": "Standard entfernen",
"dialog.server.menu.delete": "Löschen",
"dialog.server.current": "Aktueller Server",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Projekt bearbeiten",
"dialog.project.edit.name": "Name",
"dialog.project.edit.icon": "Icon",
@ -282,20 +292,20 @@ export const dict = {
"context.usage.clickToView": "Klicken, um Kontext anzuzeigen",
"context.usage.view": "Kontextnutzung anzeigen",
"language.en": "Englisch",
"language.zh": "Chinesisch (Vereinfacht)",
"language.zht": "Chinesisch (Traditionell)",
"language.ko": "Koreanisch",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Spanisch",
"language.fr": "Französisch",
"language.ja": "Japanisch",
"language.da": "Dänisch",
"language.ru": "Russisch",
"language.pl": "Polnisch",
"language.ar": "Arabisch",
"language.no": "Norwegisch",
"language.br": "Portugiesisch (Brasilien)",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "Sprache",
"toast.language.description": "Zu {{language}} gewechselt",

View file

@ -223,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCPs configured",
"dialog.lsp.empty": "LSPs auto-detected from file types",
"dialog.plugins.empty": "Plugins configured in opencode.json",
"mcp.status.connected": "connected",
"mcp.status.failed": "failed",
"mcp.status.needs_auth": "needs auth",
@ -242,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Could not connect to server",
"dialog.server.add.checking": "Checking...",
"dialog.server.add.button": "Add",
"dialog.server.add.button": "Add server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
"Connect to this server on app launch instead of starting a local server. Requires restart.",
@ -251,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "Clear",
"dialog.server.action.remove": "Remove server",
"dialog.server.menu.edit": "Edit",
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.delete": "Delete",
"dialog.server.current": "Current Server",
"dialog.server.status.default": "Default",
"dialog.project.edit.title": "Edit project",
"dialog.project.edit.name": "Name",
"dialog.project.edit.icon": "Icon",
@ -298,19 +308,19 @@ export const dict = {
"context.usage.view": "View context usage",
"language.en": "English",
"language.zh": "Chinese (Simplified)",
"language.zht": "Chinese (Traditional)",
"language.ko": "Korean",
"language.de": "German",
"language.es": "Spanish",
"language.fr": "French",
"language.ja": "Japanese",
"language.da": "Danish",
"language.ru": "Russian",
"language.pl": "Polish",
"language.ar": "Arabic",
"language.no": "Norwegian",
"language.br": "Portuguese (Brazil)",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "Language",
"toast.language.description": "Switched to {{language}}",
@ -494,6 +504,7 @@ export const dict = {
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",

View file

@ -205,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
"dialog.mcp.empty": "No hay MCPs configurados",
"dialog.lsp.empty": "LSPs detectados automáticamente por tipo de archivo",
"dialog.plugins.empty": "Plugins configurados en opencode.json",
"mcp.status.connected": "conectado",
"mcp.status.failed": "fallido",
"mcp.status.needs_auth": "necesita auth",
@ -224,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "No se pudo conectar al servidor",
"dialog.server.add.checking": "Comprobando...",
"dialog.server.add.button": "Añadir",
"dialog.server.add.button": "Añadir servidor",
"dialog.server.default.title": "Servidor predeterminado",
"dialog.server.default.description":
"Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.",
@ -233,6 +236,13 @@ export const dict = {
"dialog.server.default.clear": "Limpiar",
"dialog.server.action.remove": "Eliminar servidor",
"dialog.server.menu.edit": "Editar",
"dialog.server.menu.default": "Establecer como predeterminado",
"dialog.server.menu.defaultRemove": "Quitar predeterminado",
"dialog.server.menu.delete": "Eliminar",
"dialog.server.current": "Servidor actual",
"dialog.server.status.default": "Predeterminado",
"dialog.project.edit.title": "Editar proyecto",
"dialog.project.edit.name": "Nombre",
"dialog.project.edit.icon": "Icono",
@ -277,20 +287,20 @@ export const dict = {
"context.usage.clickToView": "Haz clic para ver contexto",
"context.usage.view": "Ver uso del contexto",
"language.en": "Inglés",
"language.zh": "Chino (simplificado)",
"language.zht": "Chino (tradicional)",
"language.ko": "Coreano",
"language.de": "Alemán",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Frans",
"language.ja": "Japonés",
"language.da": "Danés",
"language.ru": "Ruso",
"language.pl": "Polaco",
"language.ar": "Árabe",
"language.no": "Noruego",
"language.br": "Portugués (Brasil)",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "Idioma",
"toast.language.description": "Cambiado a {{language}}",

View file

@ -205,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} sur {{total}} activés",
"dialog.mcp.empty": "Aucun MCP configuré",
"dialog.lsp.empty": "LSPs détectés automatiquement par type de fichier",
"dialog.plugins.empty": "Plugins configurés dans opencode.json",
"mcp.status.connected": "connecté",
"mcp.status.failed": "échoué",
"mcp.status.needs_auth": "nécessite auth",
@ -224,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Impossible de se connecter au serveur",
"dialog.server.add.checking": "Vérification...",
"dialog.server.add.button": "Ajouter",
"dialog.server.add.button": "Ajouter un serveur",
"dialog.server.default.title": "Serveur par défaut",
"dialog.server.default.description":
"Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.",
@ -233,6 +236,13 @@ export const dict = {
"dialog.server.default.clear": "Effacer",
"dialog.server.action.remove": "Supprimer le serveur",
"dialog.server.menu.edit": "Modifier",
"dialog.server.menu.default": "Définir par défaut",
"dialog.server.menu.defaultRemove": "Supprimer par défaut",
"dialog.server.menu.delete": "Supprimer",
"dialog.server.current": "Serveur actuel",
"dialog.server.status.default": "Défaut",
"dialog.project.edit.title": "Modifier le projet",
"dialog.project.edit.name": "Nom",
"dialog.project.edit.icon": "Icône",
@ -277,20 +287,20 @@ export const dict = {
"context.usage.clickToView": "Cliquez pour voir le contexte",
"context.usage.view": "Voir l'utilisation du contexte",
"language.en": "Anglais",
"language.zh": "Chinois (simplifié)",
"language.zht": "Chinois (traditionnel)",
"language.ko": "Coréen",
"language.de": "Allemand",
"language.es": "Espagnol",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.ja": "Japonais",
"language.da": "Danois",
"language.ru": "Russe",
"language.pl": "Polonais",
"language.ar": "Arabe",
"language.no": "Norvégien",
"language.br": "Portugais (Brésil)",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "Langue",
"toast.language.description": "Passé à {{language}}",

View file

@ -204,6 +204,9 @@ export const dict = {
"dialog.mcp.description": "{{total}}個中{{enabled}}個が有効",
"dialog.mcp.empty": "MCPが設定されていません",
"dialog.lsp.empty": "ファイルタイプから自動検出されたLSP",
"dialog.plugins.empty": "opencode.jsonで設定されたプラグイン",
"mcp.status.connected": "接続済み",
"mcp.status.failed": "失敗",
"mcp.status.needs_auth": "認証が必要",
@ -223,7 +226,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "サーバーに接続できませんでした",
"dialog.server.add.checking": "確認中...",
"dialog.server.add.button": "追加",
"dialog.server.add.button": "サーバーを追加",
"dialog.server.default.title": "デフォルトサーバー",
"dialog.server.default.description":
"ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
@ -232,6 +235,13 @@ export const dict = {
"dialog.server.default.clear": "クリア",
"dialog.server.action.remove": "サーバーを削除",
"dialog.server.menu.edit": "編集",
"dialog.server.menu.default": "デフォルトに設定",
"dialog.server.menu.defaultRemove": "デフォルト設定を解除",
"dialog.server.menu.delete": "削除",
"dialog.server.current": "現在のサーバー",
"dialog.server.status.default": "デフォルト",
"dialog.project.edit.title": "プロジェクトを編集",
"dialog.project.edit.name": "名前",
"dialog.project.edit.icon": "アイコン",
@ -275,20 +285,20 @@ export const dict = {
"context.usage.clickToView": "クリックしてコンテキストを表示",
"context.usage.view": "コンテキスト使用量を表示",
"language.en": "英語",
"language.zh": "中国語(簡体字)",
"language.zht": "中国語(繁体字)",
"language.ko": "韓国語",
"language.de": "ドイツ語",
"language.es": "スペイン語",
"language.fr": "フランス語",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.da": "デンマーク語",
"language.ru": "ロシア語",
"language.pl": "ポーランド語",
"language.ar": "アラビア語",
"language.no": "ノルウェー語",
"language.br": "ポルトガル語(ブラジル)",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "言語",
"toast.language.description": "{{language}}に切り替えました",

View file

@ -208,6 +208,9 @@ export const dict = {
"dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨",
"dialog.mcp.empty": "구성된 MCP 없음",
"dialog.lsp.empty": "파일 유형에서 자동 감지된 LSP",
"dialog.plugins.empty": "opencode.json에 구성된 플러그인",
"mcp.status.connected": "연결됨",
"mcp.status.failed": "실패",
"mcp.status.needs_auth": "인증 필요",
@ -227,7 +230,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "서버에 연결할 수 없습니다",
"dialog.server.add.checking": "확인 중...",
"dialog.server.add.button": "추가",
"dialog.server.add.button": "서버 추가",
"dialog.server.default.title": "기본 서버",
"dialog.server.default.description":
"로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
@ -236,6 +239,13 @@ export const dict = {
"dialog.server.default.clear": "지우기",
"dialog.server.action.remove": "서버 제거",
"dialog.server.menu.edit": "편집",
"dialog.server.menu.default": "기본값으로 설정",
"dialog.server.menu.defaultRemove": "기본값 제거",
"dialog.server.menu.delete": "삭제",
"dialog.server.current": "현재 서버",
"dialog.server.status.default": "기본값",
"dialog.project.edit.title": "프로젝트 편집",
"dialog.project.edit.name": "이름",
"dialog.project.edit.icon": "아이콘",
@ -279,20 +289,20 @@ export const dict = {
"context.usage.clickToView": "컨텍스트를 보려면 클릭",
"context.usage.view": "컨텍스트 사용량 보기",
"language.en": "영어",
"language.zh": "중국어 (간체)",
"language.zht": "중국어 (번체)",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "독일어",
"language.es": "스페인어",
"language.fr": "프랑스어",
"language.ja": "일본어",
"language.da": "덴마크어",
"language.ru": "러시아어",
"language.pl": "폴란드어",
"language.ar": "아랍어",
"language.no": "노르웨이어",
"language.br": "포르투갈어 (브라질)",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "언어",
"toast.language.description": "{{language}}(으)로 전환됨",

View file

@ -226,6 +226,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} av {{total}} aktivert",
"dialog.mcp.empty": "Ingen MCP-er konfigurert",
"dialog.lsp.empty": "LSP-er automatisk oppdaget fra filtyper",
"dialog.plugins.empty": "Plugins konfigurert i opencode.json",
"mcp.status.connected": "tilkoblet",
"mcp.status.failed": "mislyktes",
"mcp.status.needs_auth": "trenger autentisering",
@ -245,7 +248,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Kunne ikke koble til server",
"dialog.server.add.checking": "Sjekker...",
"dialog.server.add.button": "Legg til",
"dialog.server.add.button": "Legg til server",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.",
@ -254,6 +257,13 @@ export const dict = {
"dialog.server.default.clear": "Tøm",
"dialog.server.action.remove": "Fjern server",
"dialog.server.menu.edit": "Rediger",
"dialog.server.menu.default": "Sett som standard",
"dialog.server.menu.defaultRemove": "Fjern standard",
"dialog.server.menu.delete": "Slett",
"dialog.server.current": "Gjeldende server",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Rediger prosjekt",
"dialog.project.edit.name": "Navn",
"dialog.project.edit.icon": "Ikon",
@ -297,20 +307,20 @@ export const dict = {
"context.usage.clickToView": "Klikk for å se kontekst",
"context.usage.view": "Se kontekstforbruk",
"language.en": "Engelsk",
"language.zh": "Kinesisk (forenklet)",
"language.zht": "Kinesisk (tradisjonell)",
"language.ko": "Koreansk",
"language.de": "Tysk",
"language.es": "Spansk",
"language.fr": "Fransk",
"language.ja": "Japansk",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ru": "Russisk",
"language.pl": "Polsk",
"language.ar": "Arabisk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Portugisisk (Brasil)",
"language.br": "Português (Brasil)",
"toast.language.title": "Språk",
"toast.language.description": "Byttet til {{language}}",

View file

@ -223,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} z {{total}} włączone",
"dialog.mcp.empty": "Brak skonfigurowanych MCP",
"dialog.lsp.empty": "LSP wykryte automatycznie na podstawie typów plików",
"dialog.plugins.empty": "Wtyczki skonfigurowane w opencode.json",
"mcp.status.connected": "połączono",
"mcp.status.failed": "niepowodzenie",
"mcp.status.needs_auth": "wymaga autoryzacji",
@ -242,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Nie można połączyć się z serwerem",
"dialog.server.add.checking": "Sprawdzanie...",
"dialog.server.add.button": "Dodaj",
"dialog.server.add.button": "Dodaj serwer",
"dialog.server.default.title": "Domyślny serwer",
"dialog.server.default.description":
"Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.",
@ -251,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "Wyczyść",
"dialog.server.action.remove": "Usuń serwer",
"dialog.server.menu.edit": "Edytuj",
"dialog.server.menu.default": "Ustaw jako domyślny",
"dialog.server.menu.defaultRemove": "Usuń domyślny",
"dialog.server.menu.delete": "Usuń",
"dialog.server.current": "Obecny serwer",
"dialog.server.status.default": "Domyślny",
"dialog.project.edit.title": "Edytuj projekt",
"dialog.project.edit.name": "Nazwa",
"dialog.project.edit.icon": "Ikona",
@ -294,19 +304,20 @@ export const dict = {
"context.usage.clickToView": "Kliknij, aby zobaczyć kontekst",
"context.usage.view": "Pokaż użycie kontekstu",
"language.en": "Angielski",
"language.zh": "Chiński",
"language.ko": "Koreański",
"language.de": "Niemiecki",
"language.es": "Hiszpański",
"language.fr": "Francuski",
"language.ja": "Japoński",
"language.da": "Duński",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Rosyjski",
"language.ar": "Arabski",
"language.no": "Norweski",
"language.br": "Portugalski (Brazylia)",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "Język",
"toast.language.description": "Przełączono na {{language}}",

View file

@ -223,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} из {{total}} включено",
"dialog.mcp.empty": "MCP не настроены",
"dialog.lsp.empty": "LSP автоматически обнаружены по типам файлов",
"dialog.plugins.empty": "Плагины настроены в opencode.json",
"mcp.status.connected": "подключено",
"mcp.status.failed": "ошибка",
"mcp.status.needs_auth": "требуется авторизация",
@ -242,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Не удалось подключиться к серверу",
"dialog.server.add.checking": "Проверка...",
"dialog.server.add.button": "Добавить",
"dialog.server.add.button": "Добавить сервер",
"dialog.server.default.title": "Сервер по умолчанию",
"dialog.server.default.description":
"Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
@ -251,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "Очистить",
"dialog.server.action.remove": "Удалить сервер",
"dialog.server.menu.edit": "Редактировать",
"dialog.server.menu.default": "Сделать по умолчанию",
"dialog.server.menu.defaultRemove": "Удалить по умолчанию",
"dialog.server.menu.delete": "Удалить",
"dialog.server.current": "Текущий сервер",
"dialog.server.status.default": "По умолч.",
"dialog.project.edit.title": "Редактировать проект",
"dialog.project.edit.name": "Название",
"dialog.project.edit.icon": "Иконка",
@ -295,18 +305,20 @@ export const dict = {
"context.usage.clickToView": "Нажмите для просмотра контекста",
"context.usage.view": "Показать использование контекста",
"language.en": "Английский",
"language.zh": "Китайский",
"language.ko": "Корейский",
"language.de": "Немецкий",
"language.es": "Испанский",
"language.fr": "Французский",
"language.ja": "Японский",
"language.da": "Датский",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "Арабский",
"language.no": "Норвежский",
"language.br": "Португальский (Бразилия)",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "Язык",
"toast.language.description": "Переключено на {{language}}",

View file

@ -205,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "已启用 {{enabled}} / {{total}}",
"dialog.mcp.empty": "未配置 MCPs",
"dialog.lsp.empty": "已从文件类型自动检测到 LSPs",
"dialog.plugins.empty": "在 opencode.json 中配置的插件",
"mcp.status.connected": "已连接",
"mcp.status.failed": "失败",
"mcp.status.needs_auth": "需要授权",
@ -224,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "无法连接到服务器",
"dialog.server.add.checking": "检查中...",
"dialog.server.add.button": "添加",
"dialog.server.add.button": "添加服务器",
"dialog.server.default.title": "默认服务器",
"dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
"dialog.server.default.none": "未选择服务器",
@ -232,6 +235,13 @@ export const dict = {
"dialog.server.default.clear": "清除",
"dialog.server.action.remove": "移除服务器",
"dialog.server.menu.edit": "编辑",
"dialog.server.menu.default": "设为默认",
"dialog.server.menu.defaultRemove": "取消默认",
"dialog.server.menu.delete": "删除",
"dialog.server.current": "当前服务器",
"dialog.server.status.default": "默认",
"dialog.project.edit.title": "编辑项目",
"dialog.project.edit.name": "名称",
"dialog.project.edit.icon": "图标",
@ -275,20 +285,20 @@ export const dict = {
"context.usage.clickToView": "点击查看上下文",
"context.usage.view": "查看上下文用量",
"language.en": "英语",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁中文",
"language.ko": "韩语",
"language.de": "德语",
"language.es": "西班牙语",
"language.fr": "法语",
"language.ja": "日语",
"language.da": "丹麦语",
"language.ru": "俄语",
"language.pl": "波兰语",
"language.ar": "阿拉伯语",
"language.no": "挪威语",
"language.br": "葡萄牙语(巴西)",
"language.zht": "繁中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "语言",
"toast.language.description": "已切换到{{language}}",

View file

@ -207,6 +207,9 @@ export const dict = {
"dialog.mcp.description": "已啟用 {{enabled}} / {{total}}",
"dialog.mcp.empty": "未設定 MCP",
"dialog.lsp.empty": "已從檔案類型自動偵測到 LSPs",
"dialog.plugins.empty": "在 opencode.json 中設定的外掛程式",
"mcp.status.connected": "已連線",
"mcp.status.failed": "失敗",
"mcp.status.needs_auth": "需要授權",
@ -226,7 +229,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "無法連線到伺服器",
"dialog.server.add.checking": "檢查中...",
"dialog.server.add.button": "新增",
"dialog.server.add.button": "新增伺服器",
"dialog.server.default.title": "預設伺服器",
"dialog.server.default.description": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。",
"dialog.server.default.none": "未選擇伺服器",
@ -234,6 +237,13 @@ export const dict = {
"dialog.server.default.clear": "清除",
"dialog.server.action.remove": "移除伺服器",
"dialog.server.menu.edit": "編輯",
"dialog.server.menu.default": "設為預設",
"dialog.server.menu.defaultRemove": "取消預設",
"dialog.server.menu.delete": "刪除",
"dialog.server.current": "目前伺服器",
"dialog.server.status.default": "預設",
"dialog.project.edit.title": "編輯專案",
"dialog.project.edit.name": "名稱",
"dialog.project.edit.icon": "圖示",
@ -277,14 +287,20 @@ export const dict = {
"context.usage.clickToView": "點擊查看上下文",
"context.usage.view": "檢視上下文用量",
"language.en": "英語",
"language.zh": "簡體中文",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "韓語",
"language.ru": "俄語",
"language.ar": "阿拉伯語",
"language.no": "挪威語",
"language.br": "葡萄牙語(巴西)",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"toast.language.title": "語言",
"toast.language.description": "已切換到 {{language}}",

View file

@ -1,4 +1,4 @@
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createMemo, For, Match, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { useLayout } from "@/context/layout"

View file

@ -89,11 +89,6 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined
const xlQuery = window.matchMedia("(min-width: 1280px)")
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
xlQuery.addEventListener("change", handleViewportChange)
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
const params = useParams()
const [autoselect, setAutoselect] = createSignal(!params.dir)
@ -550,11 +545,10 @@ export default function Layout(props: ParentProps) {
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
const workspaceSetting = createMemo(() => {
const project = currentProject()
if (!project) return false
if (project.vcs !== "git") return false
return layout.sidebar.workspaces(project.worktree)()
})
@ -594,7 +588,7 @@ export default function Layout(props: ParentProps) {
if (!expanded) continue
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (!project) continue
if (layout.sidebar.workspaces(project.worktree)()) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false)
}
})
@ -724,7 +718,8 @@ export default function Layout(props: ParentProps) {
if (!directory) return
const [store] = globalSync.child(directory)
if (store.message[session.id] !== undefined) return
const cached = untrack(() => store.message[session.id] !== undefined)
if (cached) return
const q = queueFor(directory)
if (q.inflight.has(session.id)) return
@ -855,14 +850,34 @@ export default function Layout(props: ParentProps) {
setStore(
produce((draft) => {
const removed = new Set<string>([session.id])
const collect = (parentID: string) => {
for (const item of draft.session) {
if (item.parentID !== parentID) continue
removed.add(item.id)
collect(item.id)
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [session.id]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
collect(session.id)
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
@ -2027,7 +2042,9 @@ export default function Layout(props: ParentProps) {
})
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
const workspaceEnabled = createMemo(
() => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
)
const [open, setOpen] = createSignal(false)
const label = (directory: string) => {
@ -2156,8 +2173,8 @@ export default function Layout(props: ParentProps) {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
layout.sidebar.open()
setOpen(false)
if (selected()) {
setOpen(false)
return
}
navigateToProject(props.project.worktree)
@ -2256,13 +2273,23 @@ export default function Layout(props: ParentProps) {
if (!created?.directory) return
const local = current.worktree
const key = workspaceKey(created.directory)
const root = workspaceKey(local)
setBusy(created.directory, true)
WorktreeState.pending(created.directory)
setStore("workspaceExpanded", created.directory, true)
setStore("workspaceExpanded", key, true)
if (key !== created.directory) {
setStore("workspaceExpanded", created.directory, true)
}
setStore("workspaceOrder", current.worktree, (prev) => {
const existing = prev ?? []
const local = current.worktree
const next = existing.filter((d) => d !== local && d !== created.directory)
const next = existing.filter((item) => {
const id = workspaceKey(item)
if (id === root) return false
return id !== key
})
return [local, created.directory, ...next]
})
@ -2277,7 +2304,7 @@ export default function Layout(props: ParentProps) {
title: language.t("workspace.new"),
category: language.t("command.category.workspace"),
keybind: "mod+shift+w",
disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(),
disabled: !workspaceSetting(),
onSelect: createWorkspace,
},
])
@ -2405,7 +2432,18 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}>
<DropdownMenu.Item
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
onSelect={() => {
const enabled = layout.sidebar.workspaces(p.worktree)()
if (enabled) {
layout.sidebar.toggleWorkspaces(p.worktree)
return
}
if (p.vcs !== "git") return
layout.sidebar.toggleWorkspaces(p.worktree)
}}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p.worktree)()
? language.t("sidebar.workspaces.disable")
@ -2423,7 +2461,7 @@ export default function Layout(props: ParentProps) {
</div>
<Show
when={layout.sidebar.workspaces(p.worktree)()}
when={workspaceSetting()}
fallback={
<>
<div class="py-4 px-3">

View file

@ -1,16 +1,4 @@
import {
For,
Index,
onCleanup,
onMount,
Show,
Match,
Switch,
createMemo,
createEffect,
on,
createSignal,
} from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@ -27,6 +15,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
@ -39,7 +28,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
@ -65,7 +54,6 @@ import {
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
@ -101,7 +89,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
const sdk = useSDK()
const readFile = (path: string) => {
const readFile = async (path: string) => {
return sdk.client.file
.read({ path })
.then((x) => x.data)
@ -190,7 +178,6 @@ export default function Page() {
const codeComponent = useCodeComponent()
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
@ -316,12 +303,22 @@ export default function Page() {
return sync.session.history.loading(id)
})
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
}, emptyUserMessages)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{
equals: same,
},
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
@ -347,13 +344,19 @@ export default function Page() {
promptHeight: 0,
})
const renderedUserMessages = createMemo(() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
}, emptyUserMessages)
const renderedUserMessages = createMemo(
() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@ -519,6 +522,7 @@ export default function Page() {
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? selectionPreview(input.file, selection)
@ -533,6 +537,7 @@ export default function Page() {
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
@ -729,7 +734,7 @@ export default function Page() {
}
const revert = info()?.revert?.messageID
// Find the last user message that's not already reverted
const message = userMessages().findLast((x) => !revert || x.id < revert)
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
// Restore the prompt from the reverted message
@ -739,7 +744,7 @@ export default function Page() {
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = userMessages().findLast((x) => x.id < message.id)
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
},
@ -761,14 +766,14 @@ export default function Page() {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
// Navigate to the last message (the one that was at the revert point)
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
// Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
// Navigate to the message before the new revert point
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
},
@ -1246,19 +1251,40 @@ export default function Page() {
autoScroll.forceScrollToBottom()
}
const closestMessage = (node: Element | null): HTMLElement | null => {
if (!node) return null
const match = node.closest?.("[data-message-id]") as HTMLElement | null
if (match) return match
const root = node.getRootNode?.()
if (root instanceof ShadowRoot) return closestMessage(root.host)
return null
}
const getActiveMessageId = (container: HTMLDivElement) => {
const rect = container.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
const hit = document.elementFromPoint(x, y)
const host = closestMessage(hit)
const id = host?.dataset.messageId
if (id) return id
// Fallback: DOM query (handles edge hit-testing cases)
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
let id: string | undefined
let last: string | undefined
for (const node of nodes) {
const next = node.dataset.messageId
if (!next) continue
if (node.offsetTop > cutoff) break
id = next
last = next
}
return id
return last
}
const scheduleScrollSpy = (container: HTMLDivElement) => {
@ -1401,7 +1427,7 @@ export default function Page() {
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
Session
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="review"
@ -1410,8 +1436,10 @@ export default function Page() {
onClick={() => setStore("mobileTab", "review")}
>
<Switch>
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
<Match when={true}>Review</Match>
<Match when={hasReview()}>
{language.t("session.review.filesChanged", { count: reviewCount() })}
</Match>
<Match when={true}>{language.t("session.tab.review")}</Match>
</Switch>
</Tabs.Trigger>
</Tabs.List>
@ -1422,7 +1450,7 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 md:flex-none py-6 md:py-3": true,
"flex-1 md:flex-none pt-6 md:pt-3": true,
}}
style={{
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
@ -1441,13 +1469,17 @@ export default function Page() {
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
fallback={
<div class="px-4 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onLineComment={addCommentToContext}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
@ -1467,7 +1499,9 @@ export default function Page() {
<Match when={true}>
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
<div class="text-13-regular text-text-weak max-w-56">
{language.t("session.review.empty")}
</div>
</div>
</Match>
</Switch>
@ -1509,9 +1543,9 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
>
<Show when={info()?.title}>
<Show when={info()?.title || info()?.parentID}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
@ -1520,8 +1554,21 @@ export default function Page() {
"md:max-w-200 md:mx-auto": !showTabs(),
}}
>
<div class="h-10 flex items-center">
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
<div class="h-10 flex items-center gap-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={info()?.title}>
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
</Show>
</div>
</div>
</Show>
@ -1635,11 +1682,11 @@ export default function Page() {
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{
"w-full md:px-6 pointer-events-auto": true,
"w-full px-4 pointer-events-auto": true,
"md:max-w-200": !showTabs(),
}}
>
@ -1699,7 +1746,7 @@ export default function Page() {
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<div>{language.t("session.tab.review")}</div>
<Show when={info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{info()?.summary?.files ?? 0}
@ -1727,7 +1774,7 @@ export default function Page() {
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>Context</div>
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
@ -1736,7 +1783,7 @@ export default function Page() {
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<TooltipKeybind
title="Open file"
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
@ -1759,14 +1806,18 @@ export default function Page() {
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
fallback={
<div class="px-6 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onLineComment={addCommentToContext}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
@ -1809,6 +1860,7 @@ export default function Page() {
let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let focusToken = 0
const path = createMemo(() => file.pathFromTab(tab))
const state = createMemo(() => {
@ -1855,7 +1907,6 @@ export default function Page() {
})
let wrap: HTMLDivElement | undefined
let textarea: HTMLTextAreaElement | undefined
const fileComments = createMemo(() => {
const p = path()
@ -1871,6 +1922,8 @@ export default function Page() {
const [positions, setPositions] = createSignal<Record<string, number>>({})
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
const empty = {} as Record<string, number>
const commentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
@ -1904,12 +1957,22 @@ export default function Page() {
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
}
const equal = (a: Record<string, number>, b: Record<string, number>) => {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
for (const key of aKeys) {
if (a[key] !== b[key]) return false
}
return true
}
const updateComments = () => {
const el = wrap
const root = getRoot()
if (!el || !root) {
setPositions({})
setDraftTop(undefined)
setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
setDraftTop((prev) => (prev === undefined ? prev : undefined))
return
}
@ -1920,7 +1983,7 @@ export default function Page() {
next[comment.id] = markerTop(el, marker)
}
setPositions(next)
setPositions((prev) => (equal(prev, next) ? prev : next))
const range = commenting()
if (!range) {
@ -1934,11 +1997,18 @@ export default function Page() {
return
}
setDraftTop(markerTop(el, marker))
const nextTop = markerTop(el, marker)
setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
}
let commentFrame: number | undefined
const scheduleComments = () => {
requestAnimationFrame(updateComments)
if (commentFrame !== undefined) return
commentFrame = requestAnimationFrame(() => {
commentFrame = undefined
updateComments()
})
}
createEffect(() => {
@ -1955,7 +2025,63 @@ export default function Page() {
const range = commenting()
if (!range) return
setDraft("")
requestAnimationFrame(() => textarea?.focus())
})
createEffect(() => {
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (activeTab() !== tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
focusToken++
const token = focusToken
setOpenedComment(target.id)
setCommenting(null)
file.setSelectedLines(p, target.selection)
const scrollTo = (attempt: number) => {
if (token !== focusToken) return
const root = scroll
if (!root) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
return
}
const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
const ready =
anchor instanceof HTMLElement &&
anchor.style.pointerEvents !== "none" &&
anchor.style.opacity !== "0"
const shadow = getRoot()
const marker = shadow ? findMarker(shadow, target.selection) : undefined
const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
if (!node) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
return
}
const rootRect = root.getBoundingClientRect()
const targetRect = node.getBoundingClientRect()
const offset = targetRect.top - rootRect.top
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
root.scrollTop = Math.max(0, next)
if (ready || marker) return
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
}
requestAnimationFrame(() => scrollTo(0))
requestAnimationFrame(() => comments.clearFocus())
})
const renderCode = (source: string, wrapperClass: string) => (
@ -2000,105 +2126,58 @@ export default function Page() {
/>
<For each={fileComments()}>
{(comment) => (
<div
class="absolute right-6 z-30"
style={{
top: `${positions()[comment.id] ?? 0}px`,
opacity: positions()[comment.id] === undefined ? 0 : 1,
"pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
<LineCommentView
id={comment.id}
top={positions()[comment.id]}
open={openedComment() === comment.id}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
>
<button
type="button"
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setOpenedComment((current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
>
<Icon name="speech-bubble" size="small" />
</button>
<Show when={openedComment() === comment.id}>
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
<div class="flex flex-col gap-1.5">
<div class="text-12-medium text-text-strong whitespace-nowrap">
{getFilename(comment.file)}:{commentLabel(comment.selection)}
</div>
<div class="text-12-regular text-text-base whitespace-pre-wrap">
{comment.comment}
</div>
</div>
</div>
</Show>
</div>
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setOpenedComment((current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
comment={comment.comment}
selection={commentLabel(comment.selection)}
/>
)}
</For>
<Show when={commenting()}>
{(range) => (
<Show when={draftTop() !== undefined}>
<div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}>
<button
type="button"
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
onClick={() => textarea?.focus()}
>
<Icon name="speech-bubble" size="small" />
</button>
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
<div class="flex flex-col gap-2">
<div class="text-12-medium text-text-strong">
Commenting on {getFilename(path() ?? "")}:{commentLabel(range())}
</div>
<textarea
ref={textarea}
class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus"
rows={3}
placeholder="Add a comment"
value={draft()}
onInput={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return
if (e.shiftKey) return
e.preventDefault()
const value = draft().trim()
if (!value) return
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value })
setCommenting(null)
}}
/>
<div class="flex justify-end gap-2">
<Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
Cancel
</Button>
<Button
size="small"
variant="secondary"
disabled={draft().trim().length === 0}
onClick={() => {
const value = draft().trim()
if (!value) return
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value })
setCommenting(null)
}}
>
Comment
</Button>
</div>
</div>
</div>
</div>
<LineCommentEditor
top={draftTop()}
value={draft()}
selection={commentLabel(range())}
onInput={setDraft}
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
const p = path()
if (!p) return
addCommentToContext({
file: p,
selection: range(),
comment,
origin: "file",
})
setCommenting(null)
}}
onPopoverFocusOut={(e) => {
const target = e.relatedTarget as Node | null
if (target && e.currentTarget.contains(target)) return
// Delay to allow click handlers to fire first
setTimeout(() => {
if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) {
setCommenting(null)
}
}, 0)
}}
/>
</Show>
)}
</Show>
@ -2228,6 +2307,7 @@ export default function Page() {
)
onCleanup(() => {
if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
@ -2384,66 +2464,23 @@ export default function Page() {
</Tabs>
<div class="flex-1 min-h-0 relative">
<For each={terminal.all()}>
{(pty) => {
const [dismissed, setDismissed] = createSignal(false)
return (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
{(pty) => (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
<Terminal
pty={pty}
onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
onConnect={() => {
terminal.update({ id: pty.id, error: false })
setDismissed(false)
}}
onConnectError={() => {
setDismissed(false)
terminal.update({ id: pty.id, error: true })
}}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(pty.id)}
/>
<Show when={pty.error && !dismissed()}>
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
>
<Icon
name="circle-ban-sign"
class="w-8 h-8"
style={{ color: "rgba(239, 68, 68, 0.8)" }}
/>
<div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
<div class="text-14-semibold mb-1">{language.t("terminal.connectionLost.title")}</div>
<div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
{language.t("terminal.connectionLost.description")}
</div>
</div>
<button
class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
style={{
"background-color": "rgba(255, 255, 255, 0.1)",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.2)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
}
onClick={() => setDismissed(true)}
>
{language.t("common.dismiss")}
</button>
</div>
</Show>
</div>
)
}}
</Show>
</div>
)}
</For>
</div>
</div>

View file

@ -106,5 +106,12 @@ export function soundSrc(id: string | undefined) {
export function playSound(src: string | undefined) {
if (typeof Audio === "undefined") return
if (!src) return
void new Audio(src).play().catch(() => undefined)
const audio = new Audio(src)
audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => {
audio.pause()
audio.currentTime = 0
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -170,22 +170,18 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
</Switch>
</li>
<Show when={!props.hideGetStarted}>
{" "}
<li>
{" "}
<A href="/download" data-slot="cta-button">
{" "}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
{" "}
<path
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>{" "}
</svg>{" "}
Free{" "}
</A>{" "}
/>
</svg>
Free
</A>
</li>
</Show>
</ul>

View file

@ -106,10 +106,13 @@
[data-slot="cta-button"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
padding: 8px 16px;
padding: 8px 16px 8px 10px;
border-radius: 4px;
font-weight: 500;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 55rem) {
display: none;

View file

@ -97,7 +97,7 @@ export default function Changelog() {
<Meta name="description" content="OpenCode release notes and changelog" />
<div data-component="container">
<Header hideGetStarted />
<Header />
<div data-component="content">
<section data-component="changelog-hero">

View file

@ -141,8 +141,6 @@ export async function POST(input: APIEvent) {
return couponID
})()
// get user
await Actor.provide("system", { workspaceID }, async () => {
// look up current billing
const billing = await Billing.get()
@ -398,32 +396,22 @@ export async function POST(input: APIEvent) {
}
*/
}
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
await Billing.unsubscribe({ subscriptionID })
}
if (body.type === "customer.subscription.deleted") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({ subscriptionID: null, subscription: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
})
await Billing.unsubscribe({ subscriptionID })
}
if (body.type === "invoice.payment_succeeded") {
if (
body.data.object.billing_reason === "subscription_cycle" ||
body.data.object.billing_reason === "subscription_create"
body.data.object.billing_reason === "subscription_create" ||
body.data.object.billing_reason === "subscription_cycle"
) {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
@ -476,6 +464,70 @@ export async function POST(input: APIEvent) {
},
}),
)
} else if (body.data.object.billing_reason === "manual") {
const workspaceID = body.data.object.metadata?.workspaceID
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
const invoiceID = body.data.object.id as string
const customerID = body.data.object.customer as string
if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!amountInCents) throw new Error("Amount not found")
if (!invoiceID) throw new Error("Invoice ID not found")
await Actor.provide("system", { workspaceID }, async () => {
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
reloadError: null,
timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(PaymentTable).values({
workspaceID: Actor.workspace(),
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
invoiceID,
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
customerID,
})
})
})
}
}
if (body.type === "invoice.payment_failed" || body.type === "invoice.payment_action_required") {
if (body.data.object.billing_reason === "manual") {
const workspaceID = body.data.object.metadata?.workspaceID
const invoiceID = body.data.object.id
if (!workspaceID) throw new Error("Workspace ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(invoiceID)
console.log(JSON.stringify(paymentIntent))
const errorMessage =
typeof paymentIntent === "object" && paymentIntent !== null
? paymentIntent.last_payment_error?.message
: undefined
await Actor.provide("system", { workspaceID }, async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: false,
reloadError: errorMessage ?? "Payment failed.",
timeReloadError: sql`now()`,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
})
}
}
if (body.type === "charge.refunded") {

View file

@ -184,7 +184,7 @@ export function ReloadSection() {
</div>
</form>
</Show>
<Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
<Show when={billingInfo()?.reloadError}>
<div data-slot="section-content">
<div data-slot="reload-error">
<p>

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.34",
"version": "1.1.36",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -78,8 +78,6 @@ export namespace Billing {
const customerID = billing.customerID
const paymentMethodID = billing.paymentMethodID
const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
const paymentID = Identifier.create("payment")
let invoice
try {
const draft = await Billing.stripe().invoices.create({
customer: customerID!,
@ -87,6 +85,10 @@ export namespace Billing {
default_payment_method: paymentMethodID!,
collection_method: "charge_automatically",
currency: "usd",
metadata: {
workspaceID: Actor.workspace(),
amount: amountInCents.toString(),
},
})
await Billing.stripe().invoiceItems.create({
amount: amountInCents,
@ -103,19 +105,17 @@ export namespace Billing {
description: ITEM_FEE_NAME,
})
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
invoice = await Billing.stripe().invoices.pay(draft.id!, {
await Billing.stripe().invoices.pay(draft.id!, {
off_session: true,
payment_method: paymentMethodID!,
expand: ["payments"],
})
if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
throw new Error(invoice.last_finalization_error?.message)
} catch (e: any) {
console.error(e)
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: false,
reloadError: e.message ?? "Payment failed.",
timeReloadError: sql`now()`,
})
@ -123,25 +123,6 @@ export namespace Billing {
)
return
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
reloadError: null,
timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(PaymentTable).values({
workspaceID: Actor.workspace(),
id: paymentID,
amount: centsToMicroCents(amountInCents),
invoiceID: invoice.id!,
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
customerID,
})
})
}
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
@ -354,4 +335,29 @@ export namespace Billing {
return subscription.id
},
)
export const unsubscribe = fn(
z.object({
subscriptionID: z.string(),
}),
async ({ subscriptionID }) => {
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({ subscriptionID: null, subscription: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
})
},
)
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.34",
"version": "1.1.36",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -43,7 +43,6 @@ uuid = { version = "1.19.0", features = ["v4"] }
tauri-plugin-decorum = "1.1.1"
comrak = { version = "0.50", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"

View file

@ -525,4 +525,4 @@ async fn spawn_local_server(
break Ok(child);
}
}
}
}

View file

@ -370,18 +370,51 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
}),
)
const errorMessage = () => {
const error = serverData.error
if (!error) return "Unknown error"
if (typeof error === "string") return error
if (error instanceof Error) return error.message
return String(error)
}
const restartApp = async () => {
await invoke("kill_sidecar").catch(() => undefined)
await relaunch().catch(() => undefined)
}
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
when={serverData.state !== "pending" && serverData()}
when={serverData.state === "errored"}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
<Show
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
}
>
{(data) => props.children(data)}
</Show>
}
>
{(data) => props.children(data)}
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
<div class="text-16-semibold">OpenCode failed to start</div>
<div class="text-12-regular opacity-70 text-center max-w-xl">
The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy)
and try again.
</div>
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
</div>
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
Restart App
</button>
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
</Show>
)
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.34",
"version": "1.1.36",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.34"
version = "1.1.36"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.34",
"version": "1.1.36",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -2,3 +2,4 @@ research
dist
gen
app.log
src/provider/models-snapshot.ts

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.34",
"version": "1.1.36",
"name": "opencode",
"type": "module",
"license": "MIT",

View file

@ -15,6 +15,16 @@ process.chdir(dir)
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`https://models.dev/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
)
console.log("Generated models-snapshot.ts")
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")

View file

@ -17,6 +17,7 @@ import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
export namespace Agent {
export const Info = z
@ -279,8 +280,8 @@ export namespace Agent {
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = SystemPrompt.header(defaultModel.providerID)
system.push(PROMPT_GENERATE)
const system = [PROMPT_GENERATE]
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
const existing = await list()
const params = {

View file

@ -570,6 +570,15 @@ function App() {
dialog.clear()
},
},
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
dialog.clear()
},
},
])
createEffect(() => {

View file

@ -570,15 +570,6 @@ export function Session() {
dialog.clear()
},
},
{
title: animationsEnabled() ? "Disable animations" : "Enable animations",
value: "session.toggle.animations",
category: "Session",
onSelect: (dialog) => {
setAnimationsEnabled((prev) => !prev)
dialog.clear()
},
},
{
title: "Page up",
value: "session.page.up",

View file

@ -14,6 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
type PermissionStage = "permission" | "always" | "reject"
@ -304,8 +305,11 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
const textareaKeybindings = useTextareaKeybindings()
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
props.onCancel()
@ -384,8 +388,11 @@ function Prompt<const T extends Record<string, string>>(props: {
})
const diffKey = Keybind.parse("ctrl+f")[0]
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)

View file

@ -3,7 +3,7 @@ import { createMemo, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { tint, useTheme } from "../../context/theme"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
@ -272,7 +272,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(index())}
>
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
<text
fg={
isActive()
? selectedForeground(theme, theme.accent)
: isAnswered()
? theme.text
: theme.textMuted
}
>
{q.header}
</text>
</box>
@ -285,7 +293,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(questions().length)}
>
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
</box>
</box>
</Show>
@ -304,7 +312,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box onMouseOver={() => moveTo(i())} onMouseUp={() => selectOption()}>
<box
onMouseOver={() => moveTo(i())}
onMouseDown={() => moveTo(i())}
onMouseUp={() => selectOption()}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
@ -329,7 +341,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}}
</For>
<Show when={custom()}>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box
onMouseOver={() => moveTo(options().length)}
onMouseDown={() => moveTo(options().length)}
onMouseUp={() => selectOption()}
>
<box flexDirection="row">
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
@ -358,6 +374,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}}
initialValue={input()}
placeholder="Type your own answer"
minHeight={1}
maxHeight={6}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}

View file

@ -149,7 +149,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function moveTo(next: number, center = false) {
setStore("selected", next)
props.onMove?.(selected()!)
const option = selected()
if (option) props.onMove?.(option)
if (!scroll) return
const target = scroll.getChildren().find((child) => {
return child.id === JSON.stringify(selected()?.value)

View file

@ -46,6 +46,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
function number(key: string) {
const value = process.env[key]

View file

@ -22,10 +22,6 @@ export namespace Global {
cache,
config,
state,
// Allow overriding models.dev URL for offline deployments
get modelsDevUrl() {
return process.env.OPENCODE_MODELS_URL || "https://models.dev"
},
}
}
@ -37,7 +33,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }),
])
const CACHE_VERSION = "18"
const CACHE_VERSION = "19"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()

View file

@ -15,7 +15,7 @@ import { CopilotAuthPlugin } from "./copilot"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.2"]
const BUILTIN = ["opencode-anthropic-auth@0.0.10", "@gitlab/opencode-gitlab-auth@1.3.2"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]

View file

@ -1,14 +0,0 @@
import { Global } from "../global"
export async function data() {
const path = Bun.env.MODELS_DEV_API_JSON
if (path) {
const file = Bun.file(path)
if (await file.exists()) {
return await file.text()
}
}
const url = Global.Path.modelsDevUrl
const json = await fetch(`${url}/api.json`).then((x) => x.text())
return json
}

View file

@ -2,9 +2,13 @@ import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import z from "zod"
import { data } from "./models-macro" with { type: "macro" }
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { lazy } from "@/util/lazy"
// Try to import bundled snapshot (generated at build time)
// Falls back to undefined in dev mode when snapshot doesn't exist
/* @ts-ignore */
export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
@ -76,28 +80,32 @@ export namespace ModelsDev {
export type Provider = z.infer<typeof Provider>
export async function get() {
refresh()
function url() {
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
}
export const Data = lazy(async () => {
const file = Bun.file(filepath)
const result = await file.json().catch(() => {})
if (result) return result as Record<string, Provider>
if (typeof data === "function") {
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
const url = Global.Path.modelsDevUrl
const json = await fetch(`${url}/api.json`).then((x) => x.text())
return JSON.parse(json) as Record<string, Provider>
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot")
.then((m) => m.snapshot as Record<string, unknown>)
.catch(() => undefined)
if (snapshot) return snapshot
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
const json = await fetch(`${url()}/api.json`).then((x) => x.text())
return JSON.parse(json)
})
export async function get() {
const result = await Data()
return result as Record<string, Provider>
}
export async function refresh() {
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return
const file = Bun.file(filepath)
log.info("refreshing", {
file,
})
const url = Global.Path.modelsDevUrl
const result = await fetch(`${url}/api.json`, {
const result = await fetch(`${url()}/api.json`, {
headers: {
"User-Agent": Installation.USER_AGENT,
},
@ -107,8 +115,19 @@ export namespace ModelsDev {
error: e,
})
})
if (result && result.ok) await Bun.write(file, await result.text())
if (result && result.ok) {
await Bun.write(file, await result.text())
ModelsDev.Data.reset()
}
}
}
setInterval(() => ModelsDev.refresh(), 60 * 1000 * 60).unref()
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH) {
ModelsDev.refresh()
setInterval(
async () => {
await ModelsDev.refresh()
},
60 * 1000 * 60,
).unref()
}

View file

@ -1,5 +1,5 @@
import type { APICallError, ModelMessage } from "ai"
import { unique } from "remeda"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
@ -26,6 +26,7 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
return "bedrock"
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return "anthropic"
case "@ai-sdk/google-vertex":
case "@ai-sdk/google":
@ -186,18 +187,12 @@ export namespace ProviderTransform {
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
if (lastContent && typeof lastContent === "object") {
lastContent.providerOptions = {
...lastContent.providerOptions,
...providerOptions,
}
lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
continue
}
}
msg.providerOptions = {
...msg.providerOptions,
...providerOptions,
}
msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
}
return msgs

View file

@ -10,7 +10,7 @@ export namespace Question {
export const Option = z
.object({
label: z.string().max(30).describe("Display text (1-5 words, concise)"),
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({
@ -21,7 +21,7 @@ export namespace Question {
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().max(30).describe("Very short label (max 30 chars)"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),

View file

@ -66,7 +66,7 @@ export namespace LLM {
])
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const system = SystemPrompt.header(input.model.providerID)
const system = []
system.push(
[
// use agent prompt otherwise provider prompt
@ -83,7 +83,11 @@ export namespace LLM {
const header = system[0]
const original = clone(system)
await Plugin.trigger("experimental.chat.system.transform", { sessionID: input.sessionID }, { system })
await Plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
if (system.length === 0) {
system.push(...original)
}

View file

@ -598,7 +598,7 @@ export namespace SessionPrompt {
agent,
abort,
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
messages: [
...MessageV2.toModelMessages(sessionMessages, model),
...(isLastStep

View file

@ -1 +0,0 @@
You are Claude Code, Anthropic's official CLI for Claude.

View file

@ -34,11 +34,6 @@ async function resolveRelativeInstruction(instruction: string): Promise<string[]
}
export namespace SystemPrompt {
export function header(providerID: string) {
if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()]
return []
}
export function instructions() {
return PROMPT_CODEX.trim()
}
@ -52,10 +47,11 @@ export namespace SystemPrompt {
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}
export async function environment() {
export async function environment(model: Provider.Model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,

View file

@ -85,7 +85,7 @@ export namespace Snapshot {
const git = gitdir()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const result =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()

View file

@ -158,6 +158,19 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
}
}
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
type: change.type,
diff: change.diff,
before: change.oldContent,
after: change.newContent,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath))
await ctx.ask({
@ -167,6 +180,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
})
@ -253,19 +267,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
}
}
// Build per-file metadata for UI rendering
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
type: change.type,
diff: change.diff,
before: change.oldContent,
after: change.newContent,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
return {
title: output,
metadata: {

View file

@ -245,6 +245,47 @@ export namespace Worktree {
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
}
type StartKind = "project" | "worktree"
async function runStartScript(directory: string, cmd: string, kind: StartKind) {
const text = cmd.trim()
if (!text) return true
const ran = await runStartCommand(directory, text)
if (ran.exitCode === 0) return true
log.error("worktree start command failed", {
kind,
directory,
message: errorText(ran),
})
return false
}
async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = await runStartScript(directory, startup, "project")
if (!ok) return false
const extra = input.extra ?? ""
await runStartScript(directory, extra, "worktree")
return true
}
function queueStartScripts(directory: string, input: { projectID: string; extra?: string }) {
setTimeout(() => {
const start = async () => {
await runStartScripts(directory, input)
}
void start().catch((error) => {
log.error("worktree start task failed", { directory, error })
})
}, 0)
}
export const create = fn(CreateInput.optional(), async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
@ -319,28 +360,7 @@ export namespace Worktree {
},
})
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const run = async (cmd: string, kind: "project" | "worktree") => {
const ran = await runStartCommand(info.directory, cmd)
if (ran.exitCode === 0) return true
log.error("worktree start command failed", {
kind,
directory: info.directory,
message: errorText(ran),
})
return false
}
if (startup) {
const ok = await run(startup, "project")
if (!ok) return
}
if (extra) {
await run(extra, "worktree")
}
await runStartScripts(info.directory, { projectID, extra })
}
void start().catch((error) => {
@ -519,8 +539,13 @@ export namespace Worktree {
}
const dirty = outputText(status.stdout)
if (!dirty) return true
if (dirty) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
}
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
const projectID = Instance.project.id
queueStartScripts(worktreePath, { projectID })
return true
})
}

View file

@ -27,18 +27,10 @@ process.env["OPENCODE_TEST_HOME"] = testHome
// Now safe to import Global (after XDG vars are set)
const { Global } = await import("../src/global")
// Pre-fetch models.json so tests don't need the macro fallback
// Also write the cache version file to prevent global/index.ts from clearing the cache
// Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode")
await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(path.join(cacheDir, "version"), "14")
const url = Global.Path.modelsDevUrl
const response = await fetch(`${url}/api.json`)
if (response.ok) {
await fs.writeFile(path.join(cacheDir, "models.json"), await response.text())
}
// Disable models.dev refresh to avoid race conditions during tests
process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true"
// Clear provider env vars to ensure clean test state
delete process.env["ANTHROPIC_API_KEY"]

View file

@ -266,23 +266,78 @@ test("unicode filenames", async () => {
expect(before).toBeTruthy()
const unicodeFiles = [
`${tmp.path}/文件.txt`,
`${tmp.path}/🚀rocket.txt`,
`${tmp.path}/café.txt`,
`${tmp.path}/файл.txt`,
{ path: `${tmp.path}/文件.txt`, content: "chinese content" },
{ path: `${tmp.path}/🚀rocket.txt`, content: "emoji content" },
{ path: `${tmp.path}/café.txt`, content: "accented content" },
{ path: `${tmp.path}/файл.txt`, content: "cyrillic content" },
]
for (const file of unicodeFiles) {
await Bun.write(file, "unicode content")
await Bun.write(file.path, file.content)
}
const patch = await Snapshot.patch(before!)
// Note: git escapes unicode characters by default, so we just check that files are detected
// The actual filenames will be escaped like "caf\303\251.txt" but functionality works
expect(patch.files.length).toBe(4)
// Skip revert test due to git filename escaping issues
// The functionality works but git uses escaped filenames internally
for (const file of unicodeFiles) {
expect(patch.files).toContain(file.path)
}
await Snapshot.revert([patch])
for (const file of unicodeFiles) {
expect(await Bun.file(file.path).exists()).toBe(false)
}
},
})
})
test("unicode filenames modification and restore", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const chineseFile = `${tmp.path}/文件.txt`
const cyrillicFile = `${tmp.path}/файл.txt`
await Bun.write(chineseFile, "original chinese")
await Bun.write(cyrillicFile, "original cyrillic")
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(chineseFile, "modified chinese")
await Bun.write(cyrillicFile, "modified cyrillic")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(chineseFile)
expect(patch.files).toContain(cyrillicFile)
await Snapshot.revert([patch])
expect(await Bun.file(chineseFile).text()).toBe("original chinese")
expect(await Bun.file(cyrillicFile).text()).toBe("original cyrillic")
},
})
})
test("unicode filenames in subdirectories", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
const deepFile = `${tmp.path}/目录/подкаталог/文件.txt`
await Bun.write(deepFile, "deep unicode content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(deepFile)
await Snapshot.revert([patch])
expect(await Bun.file(deepFile).exists()).toBe(false)
},
})
})

View file

@ -18,7 +18,21 @@ type AskInput = {
permission: string
patterns: string[]
always: string[]
metadata: { diff: string }
metadata: {
diff: string
filepath: string
files: Array<{
filePath: string
relativePath: string
type: "add" | "update" | "delete" | "move"
diff: string
before: string
after: string
additions: number
deletions: number
movePath?: string
}>
}
}
type ToolCtx = typeof baseCtx & {
@ -60,7 +74,7 @@ describe("tool.apply_patch freeform", () => {
})
test("applies add/update/delete in one patch", async () => {
await using fixture = await tmpdir()
await using fixture = await tmpdir({ git: true })
const { ctx, calls } = makeCtx()
await Instance.provide({
@ -81,6 +95,21 @@ describe("tool.apply_patch freeform", () => {
expect(result.metadata.diff).toContain("Index:")
expect(calls.length).toBe(1)
// Verify permission metadata includes files array for UI rendering
const permissionCall = calls[0]
expect(permissionCall.metadata.files).toHaveLength(3)
expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
expect(addFile).toBeDefined()
expect(addFile!.relativePath).toBe("nested/new.txt")
expect(addFile!.after).toBe("created\n")
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile).toBeDefined()
expect(updateFile!.before).toContain("line2")
expect(updateFile!.after).toContain("changed")
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
expect(added).toBe("created\n")
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
@ -89,6 +118,36 @@ describe("tool.apply_patch freeform", () => {
})
})
test("permission metadata includes move file info", async () => {
await using fixture = await tmpdir({ git: true })
const { ctx, calls } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.writeFile(original, "old content\n", "utf-8")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const permissionCall = calls[0]
expect(permissionCall.metadata.files).toHaveLength(1)
const moveFile = permissionCall.metadata.files[0]
expect(moveFile.type).toBe("move")
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
expect(moveFile.before).toBe("old content\n")
expect(moveFile.after).toBe("new content\n")
},
})
})
test("applies multiple hunks to one file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()

View file

@ -63,43 +63,44 @@ describe("tool.question", () => {
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
})
test("should throw an Error for header exceeding 30 characters", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Definitely More Than Thirty Characters Long",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
try {
await tool.execute({ questions }, ctx)
// If it reaches here, the test should fail
expect(true).toBe(false)
} catch (e: any) {
expect(e).toBeInstanceOf(Error)
expect(e.cause).toBeInstanceOf(z.ZodError)
}
})
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
// test("should throw an Error for header exceeding 30 characters", async () => {
// const tool = await QuestionTool.init()
// const questions = [
// {
// question: "What is your favorite animal?",
// header: "This Header is Definitely More Than Thirty Characters Long",
// options: [{ label: "Dog", description: "Man's best friend" }],
// },
// ]
// try {
// await tool.execute({ questions }, ctx)
// // If it reaches here, the test should fail
// expect(true).toBe(false)
// } catch (e: any) {
// expect(e).toBeInstanceOf(Error)
// expect(e.cause).toBeInstanceOf(z.ZodError)
// }
// })
test("should throw an Error for label exceeding 30 characters", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "A question with a very long label",
header: "Long Label",
options: [
{ label: "This is a very, very, very long label that will exceed the limit", description: "A description" },
],
},
]
try {
await tool.execute({ questions }, ctx)
// If it reaches here, the test should fail
expect(true).toBe(false)
} catch (e: any) {
expect(e).toBeInstanceOf(Error)
expect(e.cause).toBeInstanceOf(z.ZodError)
}
})
// test("should throw an Error for label exceeding 30 characters", async () => {
// const tool = await QuestionTool.init()
// const questions = [
// {
// question: "A question with a very long label",
// header: "Long Label",
// options: [
// { label: "This is a very, very, very long label that will exceed the limit", description: "A description" },
// ],
// },
// ]
// try {
// await tool.execute({ questions }, ctx)
// // If it reaches here, the test should fail
// expect(true).toBe(false)
// } catch (e: any) {
// expect(e).toBeInstanceOf(Error)
// expect(e.cause).toBeInstanceOf(z.ZodError)
// }
// })
})

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -203,7 +203,7 @@ export interface Hooks {
},
) => Promise<void>
"experimental.chat.system.transform"?: (
input: { sessionID: string },
input: { sessionID?: string; model: Model },
output: {
system: string[]
},

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"scripts": {
@ -20,7 +20,7 @@
"dist"
],
"devDependencies": {
"@hey-api/openapi-ts": "0.90.4",
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",

View file

@ -2518,7 +2518,17 @@ export class Control extends HeyApiClient {
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }])
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ key: "body", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<TuiControlResponseResponses, unknown, ThrowOnError>({
url: "/tui/control/response",
...options,
@ -2770,7 +2780,17 @@ export class Tui extends HeyApiClient {
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }])
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ key: "body", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<TuiPublishResponses, TuiPublishErrors, ThrowOnError>({
url: "/tui/publish",
...options,

View file

@ -7319,8 +7319,7 @@
"properties": {
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string",
"maxLength": 30
"type": "string"
},
"description": {
"description": "Explanation of choice",
@ -7338,8 +7337,7 @@
},
"header": {
"description": "Very short label (max 30 chars)",
"type": "string",
"maxLength": 30
"type": "string"
},
"options": {
"description": "Available choices",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"exports": {
@ -56,6 +56,7 @@
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",

Binary file not shown.

View file

@ -12,7 +12,7 @@
white-space: nowrap;
&[data-variant="primary"] {
background-color: var(--icon-strong-base);
background-color: var(--button-primary-base);
border-color: var(--border-weak-base);
color: var(--icon-invert-base);

View file

@ -66,28 +66,16 @@ export function Code<T>(props: CodeProps<T>) {
"selectedLines",
"commentedLines",
"onRendered",
"onLineSelectionEnd",
])
const [rendered, setRendered] = createSignal(0)
const handleLineClick: FileOptions<T>["onLineClick"] = (info) => {
props.onLineClick?.(info)
if (props.enableLineSelection !== true) return
if (info.numberColumn) return
if (!local.selectedLines) return
file().setSelectedLines(null)
}
const file = createMemo(
() =>
new File<T>(
{
...createDefaultOptions<T>("unified"),
...others,
onLineClick: props.enableLineSelection === true || props.onLineClick ? handleLineClick : undefined,
},
getWorkerPool("unified"),
),
@ -103,6 +91,19 @@ export function Code<T>(props: CodeProps<T>) {
return root
}
const applyScheme = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const scheme = document.documentElement.dataset.colorScheme
if (scheme === "dark" || scheme === "light") {
host.dataset.colorScheme = scheme
return
}
host.removeAttribute("data-color-scheme")
}
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
const root = getRoot()
if (!root) return
@ -332,12 +333,21 @@ export function Code<T>(props: CodeProps<T>) {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if (dragMoved) {
pendingSelectionEnd = true
scheduleDragUpdate()
scheduleSelectionUpdate()
if (!dragMoved) {
pendingSelectionEnd = false
const line = dragStart
setSelectedLines({ start: line, end: line })
props.onLineSelectionEnd?.(lastSelection)
dragStart = undefined
dragEnd = undefined
dragMoved = false
return
}
pendingSelectionEnd = true
scheduleDragUpdate()
scheduleSelectionUpdate()
dragStart = undefined
dragEnd = undefined
dragMoved = false
@ -372,10 +382,24 @@ export function Code<T>(props: CodeProps<T>) {
containerWrapper: container,
})
applyScheme()
setRendered((value) => value + 1)
notifyRendered()
})
createEffect(() => {
if (typeof document === "undefined") return
if (typeof MutationObserver === "undefined") return
const root = document.documentElement
const monitor = new MutationObserver(() => applyScheme())
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
applyScheme()
onCleanup(() => monitor.disconnect())
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []

View file

@ -66,7 +66,7 @@
[data-slot="dialog-header"] {
display: flex;
padding: 16px 16px 16px 24px;
padding: 20px;
justify-content: space-between;
align-items: center;
flex-shrink: 0;

View file

@ -28,11 +28,27 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const applyScheme = () => {
const scheme = document.documentElement.dataset.colorScheme
if (scheme === "dark" || scheme === "light") {
fileDiffRef.dataset.colorScheme = scheme
return
}
fileDiffRef.removeAttribute("data-color-scheme")
}
const findSide = (element: HTMLElement): "additions" | "deletions" => {
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return "additions"
if (code.hasAttribute("data-deletions")) return "deletions"
return "additions"
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
}
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
@ -45,19 +61,69 @@ export function Diff<T>(props: SSRDiffProps<T>) {
node.removeAttribute("data-comment-selected")
}
const diffs = root.querySelector("[data-diffs]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.type === "split"
const code = Array.from(diffs.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (code.length === 0) return
const lineIndex = (element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
}
}
for (const range of ranges) {
const start = Math.max(1, Math.min(range.start, range.end))
const end = Math.max(range.start, range.end)
const start = rowIndex(range.start, range.side)
if (start === undefined) continue
for (let line = start; line <= end; line++) {
const expectedSide =
line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide)
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
if (expectedSide && findSide(node) !== expectedSide) continue
node.setAttribute("data-comment-selected", "")
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const block of code) {
for (const element of Array.from(block.children)) {
if (!(element instanceof HTMLElement)) continue
const idx = lineIndex(element)
if (idx === undefined) continue
if (idx > last) break
if (idx < first) continue
element.setAttribute("data-comment-selected", "")
const next = element.nextSibling
if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) {
next.setAttribute("data-comment-selected", "")
}
}
}
}
@ -65,6 +131,16 @@ export function Diff<T>(props: SSRDiffProps<T>) {
onMount(() => {
if (isServer || !props.preloadedDiff) return
applyScheme()
if (typeof MutationObserver !== "undefined") {
const root = document.documentElement
const monitor = new MutationObserver(() => applyScheme())
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
onCleanup(() => monitor.disconnect())
}
fileDiffInstance = new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),

Some files were not shown because too many files have changed in this diff Show more