Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3fd3488ee |
@@ -1,223 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/theme.json",
|
||||
"defs": {
|
||||
"nord0": "#2E3440",
|
||||
"nord1": "#3B4252",
|
||||
"nord2": "#434C5E",
|
||||
"nord3": "#4C566A",
|
||||
"nord4": "#D8DEE9",
|
||||
"nord5": "#E5E9F0",
|
||||
"nord6": "#ECEFF4",
|
||||
"nord7": "#8FBCBB",
|
||||
"nord8": "#88C0D0",
|
||||
"nord9": "#81A1C1",
|
||||
"nord10": "#5E81AC",
|
||||
"nord11": "#BF616A",
|
||||
"nord12": "#D08770",
|
||||
"nord13": "#EBCB8B",
|
||||
"nord14": "#A3BE8C",
|
||||
"nord15": "#B48EAD"
|
||||
},
|
||||
"theme": {
|
||||
"primary": {
|
||||
"dark": "nord10",
|
||||
"light": "nord9"
|
||||
},
|
||||
"secondary": {
|
||||
"dark": "nord9",
|
||||
"light": "nord9"
|
||||
},
|
||||
"accent": {
|
||||
"dark": "nord7",
|
||||
"light": "nord7"
|
||||
},
|
||||
"error": {
|
||||
"dark": "nord11",
|
||||
"light": "nord11"
|
||||
},
|
||||
"warning": {
|
||||
"dark": "nord12",
|
||||
"light": "nord12"
|
||||
},
|
||||
"success": {
|
||||
"dark": "nord14",
|
||||
"light": "nord14"
|
||||
},
|
||||
"info": {
|
||||
"dark": "nord8",
|
||||
"light": "nord10"
|
||||
},
|
||||
"text": {
|
||||
"dark": "nord6",
|
||||
"light": "nord0"
|
||||
},
|
||||
"textMuted": {
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord1"
|
||||
},
|
||||
"background": {
|
||||
"dark": "nord0",
|
||||
"light": "nord6"
|
||||
},
|
||||
"backgroundPanel": {
|
||||
"dark": "nord1",
|
||||
"light": "nord5"
|
||||
},
|
||||
"backgroundElement": {
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
},
|
||||
"border": {
|
||||
"dark": "nord2",
|
||||
"light": "nord3"
|
||||
},
|
||||
"borderActive": {
|
||||
"dark": "nord3",
|
||||
"light": "nord2"
|
||||
},
|
||||
"borderSubtle": {
|
||||
"dark": "nord2",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffAdded": {
|
||||
"dark": "nord14",
|
||||
"light": "nord14"
|
||||
},
|
||||
"diffRemoved": {
|
||||
"dark": "nord11",
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffContext": {
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHunkHeader": {
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHighlightAdded": {
|
||||
"dark": "nord14",
|
||||
"light": "nord14"
|
||||
},
|
||||
"diffHighlightRemoved": {
|
||||
"dark": "nord11",
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffAddedBg": {
|
||||
"dark": "#36413C",
|
||||
"light": "#E6EBE7"
|
||||
},
|
||||
"diffRemovedBg": {
|
||||
"dark": "#43393D",
|
||||
"light": "#ECE6E8"
|
||||
},
|
||||
"diffContextBg": {
|
||||
"dark": "nord1",
|
||||
"light": "nord5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#303A35",
|
||||
"light": "#DDE4DF"
|
||||
},
|
||||
"diffRemovedLineNumberBg": {
|
||||
"dark": "#3C3336",
|
||||
"light": "#E4DDE0"
|
||||
},
|
||||
"markdownText": {
|
||||
"dark": "nord4",
|
||||
"light": "nord0"
|
||||
},
|
||||
"markdownHeading": {
|
||||
"dark": "nord8",
|
||||
"light": "nord10"
|
||||
},
|
||||
"markdownLink": {
|
||||
"dark": "nord9",
|
||||
"light": "nord9"
|
||||
},
|
||||
"markdownLinkText": {
|
||||
"dark": "nord7",
|
||||
"light": "nord7"
|
||||
},
|
||||
"markdownCode": {
|
||||
"dark": "nord14",
|
||||
"light": "nord14"
|
||||
},
|
||||
"markdownBlockQuote": {
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownEmph": {
|
||||
"dark": "nord12",
|
||||
"light": "nord12"
|
||||
},
|
||||
"markdownStrong": {
|
||||
"dark": "nord13",
|
||||
"light": "nord13"
|
||||
},
|
||||
"markdownHorizontalRule": {
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownListItem": {
|
||||
"dark": "nord8",
|
||||
"light": "nord10"
|
||||
},
|
||||
"markdownListEnumeration": {
|
||||
"dark": "nord7",
|
||||
"light": "nord7"
|
||||
},
|
||||
"markdownImage": {
|
||||
"dark": "nord9",
|
||||
"light": "nord9"
|
||||
},
|
||||
"markdownImageText": {
|
||||
"dark": "nord7",
|
||||
"light": "nord7"
|
||||
},
|
||||
"markdownCodeBlock": {
|
||||
"dark": "nord4",
|
||||
"light": "nord0"
|
||||
},
|
||||
"syntaxComment": {
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"syntaxKeyword": {
|
||||
"dark": "nord9",
|
||||
"light": "nord9"
|
||||
},
|
||||
"syntaxFunction": {
|
||||
"dark": "nord8",
|
||||
"light": "nord8"
|
||||
},
|
||||
"syntaxVariable": {
|
||||
"dark": "nord7",
|
||||
"light": "nord7"
|
||||
},
|
||||
"syntaxString": {
|
||||
"dark": "nord14",
|
||||
"light": "nord14"
|
||||
},
|
||||
"syntaxNumber": {
|
||||
"dark": "nord15",
|
||||
"light": "nord15"
|
||||
},
|
||||
"syntaxType": {
|
||||
"dark": "nord7",
|
||||
"light": "nord7"
|
||||
},
|
||||
"syntaxOperator": {
|
||||
"dark": "nord9",
|
||||
"light": "nord9"
|
||||
},
|
||||
"syntaxPunctuation": {
|
||||
"dark": "nord4",
|
||||
"light": "nord0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,852 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { RGBA, VignetteEffect } from "@opentui/core"
|
||||
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tabs = ["overview", "counter", "help"]
|
||||
const bind = {
|
||||
modal: "ctrl+shift+m",
|
||||
screen: "ctrl+shift+o",
|
||||
home: "escape,ctrl+h",
|
||||
left: "left,h",
|
||||
right: "right,l",
|
||||
up: "up,k",
|
||||
down: "down,j",
|
||||
alert: "a",
|
||||
confirm: "c",
|
||||
prompt: "p",
|
||||
select: "s",
|
||||
modal_accept: "enter,return",
|
||||
modal_close: "escape",
|
||||
dialog_close: "escape",
|
||||
local: "x",
|
||||
local_push: "enter,return",
|
||||
local_close: "q,backspace",
|
||||
host: "z",
|
||||
}
|
||||
|
||||
const pick = (value: unknown, fallback: string) => {
|
||||
if (typeof value !== "string") return fallback
|
||||
if (!value.trim()) return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const num = (value: unknown, fallback: number) => {
|
||||
if (typeof value !== "number") return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const rec = (value: unknown) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||
return Object.fromEntries(Object.entries(value))
|
||||
}
|
||||
|
||||
type Cfg = {
|
||||
label: string
|
||||
route: string
|
||||
vignette: number
|
||||
keybinds: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
type Route = {
|
||||
modal: string
|
||||
screen: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
tab: number
|
||||
count: number
|
||||
source: string
|
||||
note: string
|
||||
selected: string
|
||||
local: number
|
||||
}
|
||||
|
||||
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||
return {
|
||||
label: pick(options?.label, "smoke"),
|
||||
route: pick(options?.route, "workspace-smoke"),
|
||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||
keybinds: rec(options?.keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
const names = (input: Cfg) => {
|
||||
return {
|
||||
modal: `${input.route}.modal`,
|
||||
screen: `${input.route}.screen`,
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = TuiKeybindSet
|
||||
const ui = {
|
||||
panel: "#1d1d1d",
|
||||
border: "#4a4a4a",
|
||||
text: "#f0f0f0",
|
||||
muted: "#a5a5a5",
|
||||
accent: "#5f87ff",
|
||||
}
|
||||
|
||||
type Color = RGBA | string
|
||||
|
||||
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
|
||||
const value = map[name]
|
||||
if (typeof value === "string") return value
|
||||
if (value instanceof RGBA) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
const look = (map: Record<string, unknown>) => {
|
||||
return {
|
||||
panel: ink(map, "backgroundPanel", ui.panel),
|
||||
border: ink(map, "border", ui.border),
|
||||
text: ink(map, "text", ui.text),
|
||||
muted: ink(map, "textMuted", ui.muted),
|
||||
accent: ink(map, "primary", ui.accent),
|
||||
selected: ink(map, "selectedListItemText", ui.text),
|
||||
}
|
||||
}
|
||||
|
||||
const tone = (api: TuiPluginApi) => {
|
||||
return look(api.theme.current)
|
||||
}
|
||||
|
||||
type Skin = {
|
||||
panel: Color
|
||||
border: Color
|
||||
text: Color
|
||||
muted: Color
|
||||
accent: Color
|
||||
selected: Color
|
||||
}
|
||||
|
||||
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
|
||||
return (
|
||||
<box
|
||||
onMouseUp={() => {
|
||||
props.run()
|
||||
}}
|
||||
backgroundColor={props.on ? props.skin.accent : props.skin.border}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const parse = (params: Record<string, unknown> | undefined) => {
|
||||
const tab = typeof params?.tab === "number" ? params.tab : 0
|
||||
const count = typeof params?.count === "number" ? params.count : 0
|
||||
const source = typeof params?.source === "string" ? params.source : "unknown"
|
||||
const note = typeof params?.note === "string" ? params.note : ""
|
||||
const selected = typeof params?.selected === "string" ? params.selected : ""
|
||||
const local = typeof params?.local === "number" ? params.local : 0
|
||||
return {
|
||||
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
|
||||
count,
|
||||
source,
|
||||
note,
|
||||
selected,
|
||||
local: Math.max(0, local),
|
||||
}
|
||||
}
|
||||
|
||||
const current = (api: TuiPluginApi, route: Route) => {
|
||||
const value = api.route.current
|
||||
const ok = Object.values(route).includes(value.name)
|
||||
if (!ok) return parse(undefined)
|
||||
if (!("params" in value)) return parse(undefined)
|
||||
return parse(value.params)
|
||||
}
|
||||
|
||||
const opts = [
|
||||
{
|
||||
title: "Overview",
|
||||
value: 0,
|
||||
description: "Switch to overview tab",
|
||||
},
|
||||
{
|
||||
title: "Counter",
|
||||
value: 1,
|
||||
description: "Switch to counter tab",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: 2,
|
||||
description: "Switch to help tab",
|
||||
},
|
||||
]
|
||||
|
||||
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<text fg={skin.text}>
|
||||
<b>{input.label} host overlay</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
|
||||
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
|
||||
</box>
|
||||
</box>
|
||||
))
|
||||
}
|
||||
|
||||
const warn = (api: TuiPluginApi, route: Route, value: State) => {
|
||||
const DialogAlert = api.ui.DialogAlert
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogAlert
|
||||
title="Smoke alert"
|
||||
message="Testing built-in alert dialog"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const check = (api: TuiPluginApi, route: Route, value: State) => {
|
||||
const DialogConfirm = api.ui.DialogConfirm
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogConfirm
|
||||
title="Smoke confirm"
|
||||
message="Apply +1 to counter?"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
|
||||
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const entry = (api: TuiPluginApi, route: Route, value: State) => {
|
||||
const DialogPrompt = api.ui.DialogPrompt
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Smoke prompt"
|
||||
value={value.note}
|
||||
onConfirm={(note) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
|
||||
}}
|
||||
onCancel={() => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const picker = (api: TuiPluginApi, route: Route, value: State) => {
|
||||
const DialogSelect = api.ui.DialogSelect
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Smoke select"
|
||||
options={opts}
|
||||
current={value.tab}
|
||||
onSelect={(item) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, {
|
||||
...value,
|
||||
tab: typeof item.value === "number" ? item.value : value.tab,
|
||||
selected: item.title,
|
||||
source: "select",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const Screen = (props: {
|
||||
api: TuiPluginApi
|
||||
input: Cfg
|
||||
route: Route
|
||||
keys: Keys
|
||||
meta: TuiPluginMeta
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const dim = useTerminalDimensions()
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
const set = (local: number, base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
|
||||
}
|
||||
const push = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
set(next.local + 1, next)
|
||||
}
|
||||
const open = () => {
|
||||
const next = current(props.api, props.route)
|
||||
if (next.local > 0) return
|
||||
set(1, next)
|
||||
}
|
||||
const pop = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
const local = Math.max(0, next.local - 1)
|
||||
set(local, next)
|
||||
}
|
||||
const show = () => {
|
||||
setTimeout(() => {
|
||||
open()
|
||||
}, 0)
|
||||
}
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.screen) return
|
||||
const next = current(props.api, props.route)
|
||||
if (props.api.ui.dialog.open) {
|
||||
if (props.keys.match("dialog_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.ui.dialog.clear()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (next.local > 0) {
|
||||
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
pop(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local_push", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("home", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("left", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("right", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("up", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("down", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.modal, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
open()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("host", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
host(props.api, props.input, skin)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("alert", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
warn(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("confirm", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
check(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("prompt", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
entry(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("select", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
picker(props.api, props.route, next)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} screen</b>
|
||||
<span style={{ fg: skin.muted }}> plugin route</span>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||
{tabs.map((item, i) => {
|
||||
const on = value.tab === i
|
||||
return (
|
||||
<Btn
|
||||
txt={item}
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
|
||||
skin={skin}
|
||||
on={on}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexGrow={1}
|
||||
>
|
||||
{value.tab === 0 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Route: {props.route.screen}</text>
|
||||
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
|
||||
<text fg={skin.muted}>
|
||||
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
|
||||
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
|
||||
</text>
|
||||
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
|
||||
<text fg={skin.muted}>source: {value.source}</text>
|
||||
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
|
||||
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
|
||||
<text fg={skin.muted}>local stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 1 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Counter: {value.count}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("up")} / {props.keys.print("down")} change value
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 2 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
|
||||
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
|
||||
close
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingTop={1}>
|
||||
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
|
||||
<Btn txt="local overlay" run={show} skin={skin} />
|
||||
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
|
||||
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
visible={value.local > 0}
|
||||
width={dim().width}
|
||||
height={dim().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dim().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
|
||||
onMouseUp={() => {
|
||||
pop()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
onMouseUp={(evt) => {
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
width={60}
|
||||
maxWidth={dim().width - 2}
|
||||
backgroundColor={skin.panel}
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} local overlay</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="push" run={push} skin={skin} on />
|
||||
<Btn txt="pop" run={pop} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const Modal = (props: {
|
||||
api: TuiPluginApi
|
||||
input: Cfg
|
||||
route: Route
|
||||
keys: Keys
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.modal) return
|
||||
|
||||
if (props.keys.match("modal_accept", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" backgroundColor={skin.panel}>
|
||||
<Dialog onClose={() => props.api.route.navigate("home")}>
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} modal</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
|
||||
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn
|
||||
txt="open screen"
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
|
||||
skin={skin}
|
||||
on
|
||||
/>
|
||||
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</Dialog>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const home = (input: Cfg): TuiSlotPlugin => ({
|
||||
slots: {
|
||||
home_logo(ctx) {
|
||||
const map = ctx.theme.current
|
||||
const skin = look(map)
|
||||
const art = [
|
||||
" $$\\",
|
||||
" $$ |",
|
||||
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
|
||||
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
|
||||
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
|
||||
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
|
||||
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
|
||||
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
|
||||
]
|
||||
const fill = [
|
||||
skin.accent,
|
||||
skin.muted,
|
||||
ink(map, "info", ui.accent),
|
||||
skin.text,
|
||||
ink(map, "success", ui.accent),
|
||||
ink(map, "warning", ui.accent),
|
||||
ink(map, "secondary", ui.accent),
|
||||
ink(map, "error", ui.accent),
|
||||
]
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{art.map((line, i) => (
|
||||
<text fg={fill[i]}>{line}</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
},
|
||||
home_bottom(ctx) {
|
||||
const skin = look(ctx.theme.current)
|
||||
const text = "extra content in the unified home bottom slot"
|
||||
|
||||
return (
|
||||
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
|
||||
<box
|
||||
border
|
||||
borderColor={skin.border}
|
||||
backgroundColor={skin.panel}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
width="100%"
|
||||
>
|
||||
<text fg={skin.muted}>
|
||||
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
|
||||
order,
|
||||
slots: {
|
||||
sidebar_content(ctx, value) {
|
||||
const skin = look(ctx.theme.current)
|
||||
|
||||
return (
|
||||
<box
|
||||
border
|
||||
borderColor={skin.border}
|
||||
backgroundColor={skin.panel}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
>
|
||||
<text fg={skin.accent}>
|
||||
<b>{title}</b>
|
||||
</text>
|
||||
<text fg={skin.text}>{text}</text>
|
||||
<text fg={skin.muted}>
|
||||
{input.label} order {order} · session {value.session_id.slice(0, 8)}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const slot = (input: Cfg): TuiSlotPlugin[] => [
|
||||
home(input),
|
||||
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
|
||||
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
|
||||
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
|
||||
]
|
||||
|
||||
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
|
||||
const route = names(input)
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: `${input.label} modal`,
|
||||
value: "plugin.smoke.modal",
|
||||
keybind: keys.get("modal"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.modal, { source: "command" })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} screen`,
|
||||
value: "plugin.smoke.screen",
|
||||
keybind: keys.get("screen"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-screen",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} alert dialog`,
|
||||
value: "plugin.smoke.alert",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-alert",
|
||||
},
|
||||
onSelect: () => {
|
||||
warn(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} confirm dialog`,
|
||||
value: "plugin.smoke.confirm",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-confirm",
|
||||
},
|
||||
onSelect: () => {
|
||||
check(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} prompt dialog`,
|
||||
value: "plugin.smoke.prompt",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-prompt",
|
||||
},
|
||||
onSelect: () => {
|
||||
entry(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} select dialog`,
|
||||
value: "plugin.smoke.select",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-select",
|
||||
},
|
||||
onSelect: () => {
|
||||
picker(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} host overlay`,
|
||||
value: "plugin.smoke.host",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-host",
|
||||
},
|
||||
onSelect: () => {
|
||||
host(api, input, tone(api))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} go home`,
|
||||
value: "plugin.smoke.home",
|
||||
category: "Plugin",
|
||||
enabled: api.route.current.name !== "home",
|
||||
onSelect: () => {
|
||||
api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} toast`,
|
||||
value: "plugin.smoke.toast",
|
||||
category: "Plugin",
|
||||
onSelect: () => {
|
||||
api.ui.toast({
|
||||
variant: "info",
|
||||
title: "Smoke",
|
||||
message: "Plugin toast works",
|
||||
duration: 2000,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
|
||||
if (options?.enabled === false) return
|
||||
|
||||
await api.theme.install("./smoke-theme.json")
|
||||
api.theme.set("smoke-theme")
|
||||
|
||||
const value = cfg(options ?? undefined)
|
||||
const route = names(value)
|
||||
const keys = api.keybind.create(bind, value.keybinds)
|
||||
const fx = new VignetteEffect(value.vignette)
|
||||
const post = fx.apply.bind(fx)
|
||||
api.renderer.addPostProcessFn(post)
|
||||
api.lifecycle.onDispose(() => {
|
||||
api.renderer.removePostProcessFn(post)
|
||||
})
|
||||
|
||||
api.route.register([
|
||||
{
|
||||
name: route.screen,
|
||||
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
|
||||
},
|
||||
{
|
||||
name: route.modal,
|
||||
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
])
|
||||
|
||||
reg(api, value, keys)
|
||||
for (const item of slot(value)) {
|
||||
api.slots.register(item)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "tui-smoke",
|
||||
tui,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
smoke-theme.json
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": false,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"modal": "ctrl+alt+m",
|
||||
"screen": "ctrl+alt+o",
|
||||
"home": "escape,ctrl+shift+h",
|
||||
"dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -142,9 +142,9 @@
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
"@ai-sdk/openai-compatible": "1.0.1",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -305,25 +305,25 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.80",
|
||||
"@ai-sdk/google": "3.0.53",
|
||||
"@ai-sdk/google-vertex": "4.0.95",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.21",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.74",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.82",
|
||||
"@ai-sdk/anthropic": "2.0.65",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.36",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.54",
|
||||
"@ai-sdk/google-vertex": "3.0.106",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
"@ai-sdk/openai-compatible": "1.0.32",
|
||||
"@ai-sdk/perplexity": "2.0.23",
|
||||
"@ai-sdk/provider": "2.0.1",
|
||||
"@ai-sdk/provider-utils": "3.0.21",
|
||||
"@ai-sdk/togetherai": "1.0.34",
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -337,7 +337,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -347,7 +347,7 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
@@ -358,7 +358,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.0.0",
|
||||
"gitlab-ai-provider": "5.3.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -428,21 +428,11 @@
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.90",
|
||||
"@opentui/solid": ">=0.1.90",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
"@opentui/solid",
|
||||
],
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
@@ -526,7 +516,6 @@
|
||||
"motion-dom": "12.34.3",
|
||||
"motion-utils": "12.29.2",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
@@ -599,10 +588,10 @@
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch",
|
||||
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -610,7 +599,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/platform-node": "4.0.0-beta.37",
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -629,12 +618,12 @@
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "6.0.138",
|
||||
"ai": "5.0.124",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.37",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -642,7 +631,6 @@
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.0",
|
||||
"shiki": "3.20.0",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-list": "0.3.0",
|
||||
@@ -673,51 +661,51 @@
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.82", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yb1EkRCMWex0tnpHPLGQxoJEiJvMGOizuxzlXFOpuGFiYgE679NsWE/F8pHwtoAWsqLlylgGAJvJDIJ8us8LEw=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
|
||||
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
|
||||
|
||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
|
||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
|
||||
|
||||
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-htT1Y7vBN0cRu/1pGnhx6DNH3xaNr0o0MjDkmii48X2+6S/WkOzVNtMjn7V3vLWEQIWNio5vw1hG/F43K8WLHA=="],
|
||||
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-E+wzGPSa/XHmajO3WtX8mtq0ewy04tsHSpU6/SGwqbiykwWba/emi7ayZ4ir89s5OzbAen2g7T9zZiEchMfkHQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="],
|
||||
|
||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4vOEekW4TAYVHN0qgiwoUOQZhguGwZBiEw8LDeUmpWBm07QkLRAtxYCaSoMiA4hZZojao5mj6NRGEBW1CnDPtg=="],
|
||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.35", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qvh2yxL5zJS9RO/Bf12pyYBIDmn+9GR1hT6e28IYWQWnt2Xq0h9XGps6XagLAv3VYYFg8c/ozkWVd4kXLZ25HA=="],
|
||||
|
||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-K+1YprVMO8R6vTcNhqTqUWhOzX5V/hEY0pFx9KQL0/+MJjOgRi6DcOLoNBd7ONcjxYTyiFLRfk/0a/pHTtSgFA=="],
|
||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ee2At5jgV+SqC6nrtPq20iH7N/aN+O36LrA4gkzVM4cmhM7bvQKVkOXhC1XxG+wsYG6UZi3Nekoi8MEjNWuRrw=="],
|
||||
|
||||
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.40", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ARjygiBQtVSgNBp3Sag+Bkwn68ub+cZPC05UpRGG+VY8/Q896K2yU1j4I0+S1eU0BQW/9DKbRG04d9Ayi2DUmA=="],
|
||||
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.35", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.34", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-inUq29XvSVDer6JIeOkwAmCFxOtHPU0OZEhwaWoe3PI59naHIW4RIFA9wppLLV5fJI9WQcAfDKy0ZHW9nV3UJw=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uM7kpZB5l977lW7+2X1+klBUxIZQ78+1a9jHlaHFEzcOcmmslTl3sdP0QqfuuBcO0YBM2gwOiqVdp8i4TRQYcw=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VKguP0x/PUYpdQyuA/uy5pDGJy6reL0X/yDKxHfL207aCUXpFIBmyMhVs4US39dkEVhtmIFSwXauY0Pt170JRw=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/google": "3.0.53", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xL44fHlTtDM7RLkMTgyqMfkfthA38JS91bbMaHItObIhte1PAIY936ZV1PLl/Z9A/oBAXjHWbXo5xDoHzB7LEg=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.106", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/google": "2.0.54", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f9sA66bmhgJoTwa+pHWFSdYxPa0lgdQ/MgYNxZptzVyGptoziTf1a9EIXEL3jiCD0qIBAg+IhDAaYalbvZaDqQ=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gaptHgaXjMw3+eA0Q4FABcsj5nQNP6EpFaGUR+Pj5WJy7Kn6mApl975/x57224MfeJIShNpt8wFKK3tvh5ewKg=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="],
|
||||
|
||||
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
|
||||
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aiaRvnc6mhQZKhTTSXPCjPH8Iqr5D/PfCN1hgVP/3RGTBbJtsd9HemIBSABeSdAKbsMH/PwJxgnqH75HEamcBA=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="],
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="],
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
@@ -993,9 +981,9 @@
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.37", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.37", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.37", "ioredis": "^5.7.0" } }, "sha512-dCfTNYGAT+1K+nu/0jw3FL/0DJXcobZCJs9SD5XJbj1DewWPhR9/AptP6zLGj8vdP8hXem6Aa53nze3HSujW3w=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.35", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.35", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35", "ioredis": "^5.7.0" } }, "sha512-HPc2xZASl9F9y/xJ01bQgFD6Jf9XP4Fcv/BlVTvG0Yr/uN63lwKZYr/VXor5K5krHfBDeCBD8y7/SICPYZoq3A=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.40", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.40" } }, "sha512-WMRVG7T8ZDALKCOacsx2ZZj3Ccaoq8YGeD9q7ZL4q8RwQv8Nmrl+4+KZl95/zHCqXzgK9oUJOlBfQ7CZr6PQOQ=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.35", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35" } }, "sha512-9bPqNV988itKJ7MQoJuzmR014DB9EZRDOnhJt/+iJlb8qLoR9HnCzNJb9gfBdYhFmVYc8DMsQxG81rdJzpv9tg=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -1455,7 +1443,9 @@
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
|
||||
|
||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -2269,9 +2259,9 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@6.0.138", "", { "dependencies": { "@ai-sdk/gateway": "3.0.80", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-49OfPe0f5uxJ6jUdA5BBXjIinP6+ZdYfAtpF2aEH64GA5wPcxH2rf/TBUQQ0bbamBz/D+TLMV18xilZqOC+zaA=="],
|
||||
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
|
||||
|
||||
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
||||
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
@@ -2769,7 +2759,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.37", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AVMXXtb6n62W4uvo1EvT7FJ41HfDvQRX8IY2FGPvfP361dtBArKK2JtE5vmFXTsxkW90WUdvJZYpVATGIzr/BA=="],
|
||||
"effect": ["effect@4.0.0-beta.35", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-64j8dgJmoEMeq6Y3WLYcZIRqPZ5E/lqnULCf6QW5te3hQ/sa13UodWLGwBEviEqBoq72U8lArhVX+T7ntzhJGQ=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -3047,7 +3037,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.0.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -3845,7 +3835,7 @@
|
||||
|
||||
"pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
||||
|
||||
@@ -4117,8 +4107,6 @@
|
||||
|
||||
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
|
||||
|
||||
"remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="],
|
||||
|
||||
"request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
@@ -4797,21 +4785,63 @@
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.11", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
|
||||
|
||||
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="],
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
@@ -5285,6 +5315,16 @@
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="],
|
||||
|
||||
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -5503,6 +5543,12 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
@@ -5627,12 +5673,12 @@
|
||||
|
||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"vitest/@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||
@@ -5679,6 +5725,16 @@
|
||||
|
||||
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
@@ -5687,6 +5743,28 @@
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -6119,6 +6197,20 @@
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
@@ -6215,6 +6307,10 @@
|
||||
|
||||
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
@@ -6471,6 +6567,12 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
@@ -6523,6 +6625,10 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4XhUHjgqinKxOeT8K5hGAjpFA2vzOp8QpEg0uYCZwvg=",
|
||||
"aarch64-linux": "sha256-X2YTNOpJocIkWkkfS8RnuDW+tvj4riHs7CXM+cS9iv0=",
|
||||
"aarch64-darwin": "sha256-pN0rY+cpdW+6gNWeegVprdmhc2H72OZ9WxKDIs1fvJM=",
|
||||
"x86_64-darwin": "sha256-l8+Yz/6UfSPJrdgfcqy/L2SvxN2i9Apv2R0B61rpEmw="
|
||||
"x86_64-linux": "sha256-0VwVhbOtK1r16cVSZcHaI/8fUPc6aYQiUnh7Q3bSHqs=",
|
||||
"aarch64-linux": "sha256-z5b234MIS0QqDYLopyaT2hd9CAtEbcSo28y0eMfPsBs=",
|
||||
"aarch64-darwin": "sha256-sn16mtZIhF9OSBrfAHpDCJO6Nt19mdoxvYAOnwWgwDk=",
|
||||
"x86_64-darwin": "sha256-FaZpwGuWzfypA28ct86xAnW2RuFFUiXjPkr5wVTLN/o="
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -25,7 +25,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.37",
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
@@ -45,15 +45,14 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.37",
|
||||
"ai": "6.0.138",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remend": "1.3.0",
|
||||
"@playwright/test": "1.51.0",
|
||||
"typescript": "5.8.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
@@ -113,8 +112,8 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
|
||||
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,13 +465,10 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (input.sessionID && current !== input.sessionID) return false
|
||||
if (!input.sessionID && current) return false
|
||||
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||
|
||||
const state = await probeSession(page)
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (!input.sessionID && state?.sessionID) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
if (dir !== target) return false
|
||||
|
||||
@@ -19,8 +19,7 @@ export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
|
||||
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
|
||||
export const settingsFontSelector = '[data-action="settings-font"]'
|
||||
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||
|
||||
@@ -93,7 +93,7 @@ async function todoDock(page: any, sessionID: string) {
|
||||
|
||||
const write = async (driver: ComposerDriverState | undefined) => {
|
||||
await page.evaluate(
|
||||
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
|
||||
(input) => {
|
||||
const win = window as ComposerWindow
|
||||
const composer = win.__opencode_e2e?.composer
|
||||
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
||||
@@ -118,7 +118,7 @@ async function todoDock(page: any, sessionID: string) {
|
||||
}
|
||||
|
||||
const read = () =>
|
||||
page.evaluate((sessionID: string) => {
|
||||
page.evaluate((sessionID) => {
|
||||
const win = window as ComposerWindow
|
||||
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
||||
}, sessionID) as Promise<ComposerProbeState | null>
|
||||
@@ -186,8 +186,6 @@ async function withMockPermission<T>(
|
||||
opts: { child?: any } | undefined,
|
||||
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||
) {
|
||||
const listUrl = /\/permission(?:\?.*)?$/
|
||||
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
|
||||
let pending = [
|
||||
{
|
||||
...request,
|
||||
@@ -206,8 +204,7 @@ async function withMockPermission<T>(
|
||||
|
||||
const reply = async (route: any) => {
|
||||
const url = new URL(route.request().url())
|
||||
const parts = url.pathname.split("/").filter(Boolean)
|
||||
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
|
||||
const id = url.pathname.split("/").pop()
|
||||
pending = pending.filter((item) => item.id !== id)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -216,10 +213,8 @@ async function withMockPermission<T>(
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(listUrl, list)
|
||||
for (const item of replyUrls) {
|
||||
await page.route(item, reply)
|
||||
}
|
||||
await page.route("**/permission", list)
|
||||
await page.route("**/session/*/permissions/*", reply)
|
||||
|
||||
const sessionList = opts?.child
|
||||
? async (route: any) => {
|
||||
@@ -247,10 +242,8 @@ async function withMockPermission<T>(
|
||||
try {
|
||||
return await fn(state)
|
||||
} finally {
|
||||
await page.unroute(listUrl, list)
|
||||
for (const item of replyUrls) {
|
||||
await page.unroute(item, reply)
|
||||
}
|
||||
await page.unroute("**/permission", list)
|
||||
await page.unroute("**/session/*/permissions/*", reply)
|
||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,17 +28,7 @@ type Footer = {
|
||||
type Probe = {
|
||||
dir?: string
|
||||
sessionID?: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string; name?: string }
|
||||
variant?: string | null
|
||||
pick?: {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string | null
|
||||
}
|
||||
variants?: string[]
|
||||
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||
agents?: Array<{ name: string }>
|
||||
model?: { providerID: string; modelID: string }
|
||||
}
|
||||
|
||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
@@ -60,86 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
|
||||
})
|
||||
}
|
||||
|
||||
async function currentModel(page: Page) {
|
||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
|
||||
const value = await probe(page).then(modelKey)
|
||||
if (!value) throw new Error("Failed to resolve current model key")
|
||||
return value
|
||||
}
|
||||
|
||||
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((key) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
return !!win.__opencode_e2e?.model?.controls?.[key]
|
||||
}, key),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function pickAgent(page: Page, value: string) {
|
||||
await waitControl(page, "setAgent")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setAgent?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setAgent
|
||||
if (!fn) throw new Error("Model e2e agent control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
|
||||
await waitControl(page, "setModel")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setModel
|
||||
if (!fn) throw new Error("Model e2e model control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function pickVariant(page: Page, value: string) {
|
||||
await waitControl(page, "setVariant")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setVariant?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setVariant
|
||||
if (!fn) throw new Error("Model e2e variant control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function read(page: Page): Promise<Footer> {
|
||||
return {
|
||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
@@ -172,15 +82,31 @@ async function waitModel(page: Page, value: string) {
|
||||
async function choose(page: Page, root: string, value: string) {
|
||||
const select = page.locator(root)
|
||||
await expect(select).toBeVisible()
|
||||
await pickAgent(page, value)
|
||||
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||
const item = page
|
||||
.locator('[data-slot="select-select-item"]')
|
||||
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
|
||||
.first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
}
|
||||
|
||||
async function variantCount(page: Page) {
|
||||
return (await probe(page))?.variants?.length ?? 0
|
||||
const select = page.locator(promptVariantSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
const count = await page.locator('[data-slot="select-select-item"]').count()
|
||||
await page.keyboard.press("Escape")
|
||||
return count
|
||||
}
|
||||
|
||||
async function agents(page: Page) {
|
||||
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
|
||||
const select = page.locator(promptAgentSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
|
||||
await page.keyboard.press("Escape")
|
||||
return labels.map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||
@@ -206,23 +132,48 @@ async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||
|
||||
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
|
||||
if (!next) throw new Error("Current model has no alternate variant to select")
|
||||
const select = page.locator(promptVariantSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
await pickVariant(page, next)
|
||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
const count = await items.count()
|
||||
if (count < 2) throw new Error("Current model has no alternate variant to select")
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = items.nth(i)
|
||||
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
|
||||
if (!next || next === current.variant) continue
|
||||
await item.click()
|
||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||
}
|
||||
|
||||
throw new Error("Failed to choose a different variant")
|
||||
}
|
||||
|
||||
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
|
||||
const current = await currentModel(page)
|
||||
const next = (await probe(page))?.models?.find((item) => {
|
||||
const key = `${item.providerID}:${item.modelID}`
|
||||
return key !== current && !skip.includes(key)
|
||||
})
|
||||
if (!next) throw new Error("Failed to choose a different model")
|
||||
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
|
||||
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
|
||||
return read(page)
|
||||
async function chooseOtherModel(page: Page): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
const items = dialog.locator('[data-slot="list-item"]')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = items.nth(i)
|
||||
const selected = (await item.getAttribute("data-selected")) === "true"
|
||||
if (selected) continue
|
||||
await item.click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
|
||||
return read(page)
|
||||
}
|
||||
|
||||
throw new Error("Failed to choose a different model")
|
||||
}
|
||||
|
||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||
@@ -298,14 +249,17 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||
}
|
||||
|
||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
||||
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||
page,
|
||||
withProject,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
await ensureVariant(page, directory)
|
||||
const firstState = await chooseDifferentVariant(page)
|
||||
const first = await submit(page, `session variant ${Date.now()}`)
|
||||
trackSession(first)
|
||||
await waitUser(directory, first)
|
||||
@@ -315,10 +269,10 @@ test("session model restore per session without leaking into new sessions", asyn
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await gotoSession()
|
||||
const fresh = await read(page)
|
||||
expect(fresh.model).not.toBe(firstState.model)
|
||||
const fresh = await ensureVariant(page, directory)
|
||||
expect(fresh.variant).not.toBe(firstState.variant)
|
||||
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const secondState = await chooseOtherModel(page)
|
||||
const second = await submit(page, `session model ${Date.now()}`)
|
||||
trackSession(second)
|
||||
await waitUser(directory, second)
|
||||
@@ -340,8 +294,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
|
||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
await ensureVariant(page, root)
|
||||
const firstState = await chooseDifferentVariant(page)
|
||||
const first = await submit(page, `root session ${Date.now()}`)
|
||||
trackSession(first, root)
|
||||
await waitUser(root, first)
|
||||
@@ -353,8 +307,7 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
|
||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||
trackDirectory(oneDir)
|
||||
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const secondKey = await currentModel(page)
|
||||
const secondState = await chooseOtherModel(page)
|
||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
||||
trackSession(second, oneDir)
|
||||
await waitUser(oneDir, second)
|
||||
@@ -363,7 +316,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
|
||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||
trackDirectory(twoDir)
|
||||
|
||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||
await ensureVariant(page, twoDir)
|
||||
const thirdState = await chooseDifferentVariant(page)
|
||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
||||
trackSession(third, twoDir)
|
||||
await waitUser(twoDir, third)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect, settingsKey } from "../fixtures"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
import {
|
||||
settingsColorSchemeSelector,
|
||||
settingsCodeFontSelector,
|
||||
settingsFontSelector,
|
||||
settingsLanguageSelectSelector,
|
||||
settingsNotificationsAgentSelector,
|
||||
settingsNotificationsErrorsSelector,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
settingsSoundsErrorsSelector,
|
||||
settingsSoundsPermissionsSelector,
|
||||
settingsThemeSelector,
|
||||
settingsUIFontSelector,
|
||||
settingsUpdatesStartupSelector,
|
||||
} from "../selectors"
|
||||
|
||||
@@ -153,199 +152,39 @@ test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsCodeFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
const select = dialog.locator(settingsFontSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const initialUIFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||
|
||||
const next = "Test Mono"
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially(next)
|
||||
await expect(input).toHaveValue(next)
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
await items.nth(2).click()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: next,
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const newFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const newUIFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(newFontFamily).toContain(next)
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
|
||||
|
||||
const newFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
expect(newUIFamily).toBe(initialUIFamily)
|
||||
})
|
||||
|
||||
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsUIFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const initialCodeFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(initialFontFamily).toContain("Inter")
|
||||
|
||||
const next = "Test Sans"
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially(next)
|
||||
await expect(input).toHaveValue(next)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: next,
|
||||
},
|
||||
})
|
||||
|
||||
const newFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const newCodeFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(newFontFamily).toContain(next)
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
expect(newCodeFamily).toBe(initialCodeFamily)
|
||||
})
|
||||
|
||||
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsCodeFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially("Reset Mono")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "Reset Mono",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("IBM Plex Mono")
|
||||
expect(fontFamily).not.toContain("Reset Mono")
|
||||
})
|
||||
|
||||
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsUIFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially("Reset Sans")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "Reset Sans",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("Inter")
|
||||
expect(fontFamily).not.toContain("Reset Sans")
|
||||
})
|
||||
|
||||
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
@@ -356,35 +195,31 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
|
||||
const code = dialog.locator(settingsCodeFontSelector)
|
||||
const ui = dialog.locator(settingsUIFontSelector)
|
||||
await expect(code).toBeVisible()
|
||||
await expect(ui).toBeVisible()
|
||||
const fontSelect = dialog.locator(settingsFontSelector)
|
||||
await expect(fontSelect).toBeVisible()
|
||||
|
||||
const initialMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const initialSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
|
||||
const initialSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
||||
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
||||
const currentFont =
|
||||
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
await code.click()
|
||||
await code.clear()
|
||||
await code.pressSequentially(mono)
|
||||
await expect(code).toHaveValue(mono)
|
||||
const fontItems = page.locator('[data-slot="select-select-item"]')
|
||||
expect(await fontItems.count()).toBeGreaterThan(1)
|
||||
|
||||
await ui.click()
|
||||
await ui.clear()
|
||||
await ui.pressSequentially(sans)
|
||||
await expect(ui).toHaveValue(sans)
|
||||
if (currentFont) {
|
||||
await fontItems.filter({ hasNotText: currentFont }).first().click()
|
||||
}
|
||||
if (!currentFont) {
|
||||
await fontItems.nth(1).click()
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -395,8 +230,7 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
font: expect.any(String),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -405,18 +239,11 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const updatedMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const updatedSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(updatedMono).toContain(mono)
|
||||
expect(updatedMono).not.toBe(initialMono)
|
||||
expect(updatedSans).toContain(sans)
|
||||
expect(updatedSans).not.toBe(initialSans)
|
||||
expect(updatedSettings?.appearance?.font).toBe(mono)
|
||||
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
|
||||
const updatedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(updatedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await page.reload()
|
||||
@@ -432,8 +259,7 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
font: updatedSettings?.appearance?.font,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -444,32 +270,17 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
return await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
})
|
||||
.toContain(mono)
|
||||
.not.toBe(initialFontFamily)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
})
|
||||
.toContain(sans)
|
||||
|
||||
const rehydratedMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const rehydratedSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(rehydratedMono).toContain(mono)
|
||||
expect(rehydratedMono).not.toBe(initialMono)
|
||||
expect(rehydratedSans).toContain(sans)
|
||||
expect(rehydratedSans).not.toBe(initialSans)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(mono)
|
||||
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
|
||||
const rehydratedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
|
||||
})
|
||||
|
||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||
|
||||
@@ -37,6 +37,7 @@ import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
@@ -76,6 +77,11 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function MarkedProviderWithNativeParser(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||
}
|
||||
|
||||
function QueryProvider(props: ParentProps) {
|
||||
const client = new QueryClient()
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
@@ -138,9 +144,9 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -15,20 +15,13 @@ import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
@@ -40,11 +33,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
timer.current = undefined
|
||||
})
|
||||
|
||||
const provider = createMemo(
|
||||
() =>
|
||||
providers.all().find((x) => x.id === props.provider) ??
|
||||
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
||||
)
|
||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
@@ -344,7 +333,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
|
||||
function goBack() {
|
||||
if (methods().length === 1) {
|
||||
all()
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
return
|
||||
}
|
||||
if (store.authorization) {
|
||||
@@ -355,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
all()
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
function MethodSelection() {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Component, createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const statusLabels = {
|
||||
@@ -20,48 +18,6 @@ export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({
|
||||
done: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.mcp_ready,
|
||||
(ready, prev) => {
|
||||
if (!ready && prev) setState("done", false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (state.done || state.loading) return
|
||||
if (sync.data.mcp_ready) {
|
||||
setState("done", true)
|
||||
return
|
||||
}
|
||||
|
||||
setState("loading", true)
|
||||
void sdk.client.mcp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("mcp", result.data ?? {})
|
||||
sync.set("mcp_ready", true)
|
||||
setState("done", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setState("done", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setState("loading", false)
|
||||
})
|
||||
})
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -19,18 +21,6 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
|
||||
const connect = (provider: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
||||
})
|
||||
}
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") return
|
||||
@@ -101,7 +91,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
connect(x.id)
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
@@ -132,7 +122,9 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||
icon="dot-grid"
|
||||
onClick={all}
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogManageModels } from "./dialog-manage-models"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -105,16 +107,12 @@ export function ModelSelectorPopover(props: {
|
||||
|
||||
const handleManage = () => {
|
||||
setStore("open", false)
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
dialog.show(() => <DialogManageModels />)
|
||||
}
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
setStore("open", false)
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -195,29 +193,26 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
const manage = () => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={language.t("dialog.model.select.title")}
|
||||
action={
|
||||
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
|
||||
<Button
|
||||
class="h-7 -my-1 text-14-medium"
|
||||
icon="plus-small"
|
||||
tabIndex={-1}
|
||||
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
||||
>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||
onClick={() => dialog.show(() => <DialogManageModels />)}
|
||||
>
|
||||
{language.t("dialog.model.manage")}
|
||||
</Button>
|
||||
</Dialog>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -1493,11 +1494,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
}}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
@@ -33,7 +32,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
@@ -52,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
@@ -93,7 +92,6 @@ const emptyUserMessages: UserMessage[] = []
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
@@ -132,7 +130,7 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
|
||||
@@ -4,21 +4,12 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
@@ -34,6 +25,13 @@ type ThemeOption = {
|
||||
name: string
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
// 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 stopDemoSound = () => {
|
||||
@@ -151,10 +149,25 @@ export const SettingsGeneral: Component = () => {
|
||||
})),
|
||||
)
|
||||
|
||||
const fontOptions = [
|
||||
{ value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
|
||||
{ value: "cascadia-code", label: "font.option.cascadiaCode" },
|
||||
{ value: "fira-code", label: "font.option.firaCode" },
|
||||
{ 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" },
|
||||
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
|
||||
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
|
||||
{ value: "geist-mono", label: "font.option.geistMono" },
|
||||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
@@ -321,50 +334,31 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-ui-font"
|
||||
label={language.t("settings.general.row.uiFont.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={sans()}
|
||||
onChange={(value) => settings.appearance.setUIFont(value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-code-font"
|
||||
label={language.t("settings.general.row.font.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={mono()}
|
||||
onChange={(value) => settings.appearance.setFont(value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
data-action="settings-font"
|
||||
options={fontOptionsList}
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||
}}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
{option ? language.t(option.label) : ""}
|
||||
</span>
|
||||
)}
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
|
||||
const [load, setLoad] = createStore({
|
||||
lspDone: false,
|
||||
lspLoading: false,
|
||||
mcpDone: false,
|
||||
mcpLoading: false,
|
||||
})
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.shown()) return
|
||||
|
||||
if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
|
||||
setLoad("mcpLoading", true)
|
||||
void sdk.client.mcp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("mcp", result.data ?? {})
|
||||
sync.set("mcp_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoad("mcpDone", true)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoad("mcpLoading", false)
|
||||
})
|
||||
}
|
||||
|
||||
if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
|
||||
setLoad("lspLoading", true)
|
||||
void sdk.client.lsp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("lsp", result.data ?? [])
|
||||
sync.set("lsp_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoad("lspDone", true)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoad("lspLoading", false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, props.shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() =>
|
||||
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
)
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.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()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const blocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<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={{
|
||||
"hover:bg-surface-raised-base-hover": !blocked(),
|
||||
"cursor-not-allowed": blocked(),
|
||||
}}
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={blocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</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={mcpNames().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => 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={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(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">{language.t("dialog.lsp.empty")}</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">{pluginEmpty()}</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>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,202 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
const pollMs = 10_000
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
||||
const healthy = createMemo(() => {
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
const overallHealthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
|
||||
return serverHealthy && !issue
|
||||
const anyMcpIssue = mcpNames().some((name) => {
|
||||
const status = mcpStatus(name)
|
||||
return status !== "connected" && status !== "disabled"
|
||||
})
|
||||
return serverHealthy && !anyMcpIssue
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -40,9 +218,9 @@ export function StatusPopover() {
|
||||
<div
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
|
||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -52,15 +230,205 @@ export function StatusPopover() {
|
||||
placement="bottom-end"
|
||||
shift={-168}
|
||||
>
|
||||
<Show when={shown()}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
>
|
||||
<Body shown={shown} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.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()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const isBlocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<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={{
|
||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||
"cursor-not-allowed": isBlocked(),
|
||||
}}
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={isBlocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</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={mcpNames().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
{language.t("dialog.mcp.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => 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={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(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">
|
||||
{language.t("dialog.lsp.empty")}
|
||||
</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">{pluginEmpty()}</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
|
||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
@@ -154,7 +154,6 @@ function createGlobalSync() {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearProviderRev(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
},
|
||||
translate: language.t,
|
||||
@@ -253,7 +252,6 @@ function createGlobalSync() {
|
||||
directory,
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
path: globalStore.path,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
@@ -313,10 +311,7 @@ function createGlobalSync() {
|
||||
loadLsp: () => {
|
||||
sdkFor(directory)
|
||||
.lsp.status()
|
||||
.then((x) => {
|
||||
setStore("lsp", x.data ?? [])
|
||||
setStore("lsp_ready", true)
|
||||
})
|
||||
.then((x) => setStore("lsp", x.data ?? []))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
@@ -53,12 +52,6 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
const providerRev = new Map<string, number>()
|
||||
|
||||
export function clearProviderRev(directory: string) {
|
||||
providerRev.delete(directory)
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
@@ -151,40 +144,6 @@ function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
||||
setStore("session", (list) => {
|
||||
const next = list.slice()
|
||||
const idx = next.findIndex((item) => item.id >= session.id)
|
||||
if (idx === -1) return [...next, session]
|
||||
if (next[idx]?.id === session.id) {
|
||||
next[idx] = session
|
||||
return next
|
||||
}
|
||||
next.splice(idx, 0, session)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
sdk: OpencodeClient
|
||||
}) {
|
||||
const known = new Set(input.store.session.map((item) => item.id))
|
||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
||||
const session = x.data
|
||||
if (!session?.id) return
|
||||
mergeSession(input.setStore, session)
|
||||
}),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
@@ -195,29 +154,19 @@ export async function bootstrapDirectory(input: {
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
global: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (seededPath) input.setStore("path", seededPath)
|
||||
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||
input.setStore("provider", input.global.provider)
|
||||
}
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", input.global.config)
|
||||
}
|
||||
if (loading || input.store.provider.all.length === 0) {
|
||||
input.setStore("provider_ready", false)
|
||||
}
|
||||
input.setStore("mcp_ready", false)
|
||||
input.setStore("mcp", {})
|
||||
input.setStore("lsp_ready", false)
|
||||
input.setStore("lsp", [])
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const fast = [
|
||||
@@ -228,15 +177,13 @@ export async function bootstrapDirectory(input: {
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
seededPath
|
||||
? Promise.resolve()
|
||||
: retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
@@ -250,66 +197,61 @@ export async function bootstrapDirectory(input: {
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.mcp.status().then((x) => {
|
||||
input.setStore("mcp", x.data!)
|
||||
input.setStore("mcp_ready", true)
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||
]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
@@ -336,23 +278,4 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
|
||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||
providerRev.set(input.directory, rev)
|
||||
void retry(() => input.sdk.provider.list())
|
||||
.then((x) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
input.setStore("provider_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
console.error("Failed to refresh provider list", err)
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ export function createChildStoreManager(input: {
|
||||
project: "",
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
provider_ready: false,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
@@ -174,9 +173,7 @@ export function createChildStoreManager(input: {
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: false,
|
||||
mcp: {},
|
||||
lsp_ready: false,
|
||||
lsp: [],
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
|
||||
@@ -38,7 +38,6 @@ export type State = {
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: ProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
@@ -59,11 +58,9 @@ export type State = {
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
lsp_ready: boolean
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
limit: number
|
||||
|
||||
@@ -13,8 +13,7 @@ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
const DEFAULT_SIDEBAR_WIDTH = 344
|
||||
const DEFAULT_FILE_TREE_WIDTH = 200
|
||||
const DEFAULT_PANEL_WIDTH = 344
|
||||
const DEFAULT_SESSION_WIDTH = 600
|
||||
const DEFAULT_TERMINAL_HEIGHT = 280
|
||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||
@@ -162,11 +161,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (!isRecord(fileTree)) return fileTree
|
||||
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
||||
|
||||
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
|
||||
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
|
||||
return {
|
||||
...fileTree,
|
||||
opened: true,
|
||||
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
|
||||
width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
|
||||
tab: "changes",
|
||||
}
|
||||
})()
|
||||
@@ -231,7 +230,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
createStore({
|
||||
sidebar: {
|
||||
opened: false,
|
||||
width: DEFAULT_SIDEBAR_WIDTH,
|
||||
width: DEFAULT_PANEL_WIDTH,
|
||||
workspaces: {} as Record<string, boolean>,
|
||||
workspacesDefault: false,
|
||||
},
|
||||
@@ -244,8 +243,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
panelOpened: true,
|
||||
},
|
||||
fileTree: {
|
||||
opened: false,
|
||||
width: DEFAULT_FILE_TREE_WIDTH,
|
||||
opened: true,
|
||||
width: DEFAULT_PANEL_WIDTH,
|
||||
tab: "changes" as "changes" | "all",
|
||||
},
|
||||
session: {
|
||||
@@ -629,32 +628,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
fileTree: {
|
||||
opened: createMemo(() => store.fileTree?.opened ?? true),
|
||||
width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
|
||||
width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
|
||||
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
||||
setTab(tab: "changes" | "all") {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab })
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "tab", tab)
|
||||
},
|
||||
open() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", true)
|
||||
},
|
||||
close() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
||||
setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
|
||||
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", (x) => !x)
|
||||
|
||||
@@ -390,18 +390,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
if (modelEnabled()) {
|
||||
const probe = Symbol("model-probe")
|
||||
|
||||
modelProbe.bind(probe, {
|
||||
setAgent: agent.set,
|
||||
setModel: model.set,
|
||||
setVariant: model.variant.set,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const agent = result.agent.current()
|
||||
const model = result.model.current()
|
||||
modelProbe.set(probe, {
|
||||
modelProbe.set({
|
||||
dir: sdk.directory,
|
||||
sessionID: id(),
|
||||
last: store.last,
|
||||
@@ -419,20 +411,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
pick: scope(),
|
||||
base: undefined,
|
||||
current: store.current,
|
||||
variants: result.model.variant.list(),
|
||||
models: result.model
|
||||
.list()
|
||||
.filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id }))
|
||||
.map((item) => ({
|
||||
providerID: item.provider.id,
|
||||
modelID: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
agents: result.agent.list().map((item) => ({ name: item.name })),
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => modelProbe.clear(probe))
|
||||
onCleanup(() => modelProbe.clear())
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface Settings {
|
||||
appearance: {
|
||||
fontSize: number
|
||||
font: string
|
||||
uiFont: string
|
||||
}
|
||||
keybinds: Record<string, string>
|
||||
permissions: {
|
||||
@@ -43,56 +42,13 @@ export interface Settings {
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
export const monoDefault = "IBM Plex Mono"
|
||||
export const sansDefault = "Inter"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
|
||||
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
|
||||
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
|
||||
const monoKey = "ibm-plex-mono"
|
||||
|
||||
function input(font: string | undefined, key?: string) {
|
||||
if (!font || font === key || !font.trim()) return ""
|
||||
return font
|
||||
}
|
||||
|
||||
function family(font: string) {
|
||||
if (/^[\w-]+$/.test(font)) return font
|
||||
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
function stack(font: string | undefined, base: string, key?: string) {
|
||||
const value = input(font, key).trim()
|
||||
if (!value) return base
|
||||
return `${family(value)}, ${base}`
|
||||
}
|
||||
|
||||
export function monoInput(font: string | undefined) {
|
||||
return input(font, monoKey)
|
||||
}
|
||||
|
||||
export function sansInput(font: string | undefined) {
|
||||
return input(font)
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return stack(font, monoBase, monoKey)
|
||||
}
|
||||
|
||||
export function sansFontFamily(font: string | undefined) {
|
||||
return stack(font, sansBase)
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
followup: "steer",
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: false,
|
||||
shellToolPartsExpanded: true,
|
||||
editToolPartsExpanded: false,
|
||||
},
|
||||
updates: {
|
||||
@@ -100,8 +56,7 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
font: "",
|
||||
uiFont: "",
|
||||
font: "ibm-plex-mono",
|
||||
},
|
||||
keybinds: {},
|
||||
permissions: {
|
||||
@@ -122,10 +77,40 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
const monoFonts: Record<string, string> = {
|
||||
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
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}`,
|
||||
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||
}
|
||||
|
||||
function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
@@ -133,9 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
|
||||
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||
if (id !== defaultSettings.appearance.font) {
|
||||
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||
}
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -191,11 +178,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "font", value.trim() ? value : "")
|
||||
},
|
||||
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
|
||||
setUIFont(value: string) {
|
||||
setStore("appearance", "uiFont", value.trim() ? value : "")
|
||||
setStore("appearance", "font", value)
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
if (projectStore.provider_ready) return projectStore.provider
|
||||
if (projectStore.provider.all.length > 0) return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
}
|
||||
|
||||
@@ -564,10 +564,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
|
||||
"settings.general.row.theme.title": "السمة",
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "خط الكود",
|
||||
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
|
||||
"settings.general.row.uiFont.title": "خط الواجهة",
|
||||
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
|
||||
"settings.general.row.followup.option.queue": "قائمة انتظار",
|
||||
@@ -594,6 +592,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "جارٍ التحقق...",
|
||||
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
|
||||
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "بلا",
|
||||
"sound.option.alert01": "تنبيه 01",
|
||||
"sound.option.alert02": "تنبيه 02",
|
||||
|
||||
@@ -571,10 +571,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte de código",
|
||||
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
|
||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||
"settings.general.row.followup.description":
|
||||
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
||||
@@ -602,6 +600,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Verificando...",
|
||||
"settings.updates.toast.latest.title": "Você está atualizado",
|
||||
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nenhum",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
|
||||
@@ -636,10 +636,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
||||
"settings.general.row.font.title": "Font za kod",
|
||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
|
||||
"settings.general.row.uiFont.title": "UI font",
|
||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
||||
"settings.general.row.followup.option.queue": "Red čekanja",
|
||||
@@ -669,6 +667,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Provjera...",
|
||||
"settings.updates.toast.latest.title": "Sve je ažurno",
|
||||
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nijedan",
|
||||
"sound.option.alert01": "Upozorenje 01",
|
||||
"sound.option.alert02": "Upozorenje 02",
|
||||
|
||||
@@ -631,10 +631,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
||||
"settings.general.row.font.title": "Kode-skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
|
||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
||||
"settings.general.row.font.title": "Skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
|
||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
||||
"settings.general.row.followup.option.queue": "Kø",
|
||||
@@ -664,6 +662,19 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "Du er opdateret",
|
||||
"settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ingen",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
|
||||
@@ -581,10 +581,8 @@ export const dict = {
|
||||
"Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
|
||||
"settings.general.row.theme.title": "Thema",
|
||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||
"settings.general.row.font.title": "Code-Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
|
||||
"settings.general.row.uiFont.title": "UI-Schriftart",
|
||||
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
|
||||
"settings.general.row.font.title": "Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
||||
"settings.general.row.followup.description":
|
||||
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
|
||||
@@ -613,6 +611,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Wird geprüft...",
|
||||
"settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
|
||||
"settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Keine",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
|
||||
@@ -729,10 +729,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
|
||||
"settings.general.row.theme.title": "Theme",
|
||||
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
||||
"settings.general.row.font.title": "Code Font",
|
||||
"settings.general.row.font.description": "Customise the font used in code blocks and terminals",
|
||||
"settings.general.row.uiFont.title": "UI Font",
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
"settings.general.row.followup.title": "Follow-up behavior",
|
||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
@@ -762,6 +760,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Checking...",
|
||||
"settings.updates.toast.latest.title": "You're up to date",
|
||||
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "None",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
|
||||
@@ -639,10 +639,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
||||
"settings.general.row.font.title": "Fuente de código",
|
||||
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
|
||||
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
||||
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
||||
"settings.general.row.font.title": "Fuente",
|
||||
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||
"settings.general.row.followup.description":
|
||||
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
|
||||
@@ -674,6 +672,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Buscando...",
|
||||
"settings.updates.toast.latest.title": "Estás al día",
|
||||
"settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ninguno",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
|
||||
@@ -578,10 +578,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
|
||||
"settings.general.row.theme.title": "Thème",
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||
"settings.general.row.font.title": "Police de code",
|
||||
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
|
||||
"settings.general.row.uiFont.title": "Police de l'interface",
|
||||
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
||||
"settings.general.row.font.title": "Police",
|
||||
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||
"settings.general.row.followup.title": "Comportement de suivi",
|
||||
"settings.general.row.followup.description":
|
||||
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
|
||||
@@ -610,6 +608,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Vérification...",
|
||||
"settings.updates.toast.latest.title": "Vous êtes à jour",
|
||||
"settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Aucun",
|
||||
"sound.option.alert01": "Alerte 01",
|
||||
"sound.option.alert02": "Alerte 02",
|
||||
|
||||
@@ -568,10 +568,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "OpenCodeがシステム、ライト、またはダークテーマに従うかを選択します",
|
||||
"settings.general.row.theme.title": "テーマ",
|
||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||
"settings.general.row.font.title": "コードフォント",
|
||||
"settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
|
||||
"settings.general.row.uiFont.title": "UIフォント",
|
||||
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
|
||||
"settings.general.row.font.title": "フォント",
|
||||
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||
"settings.general.row.followup.title": "フォローアップの動作",
|
||||
"settings.general.row.followup.description":
|
||||
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
|
||||
@@ -599,6 +597,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "確認中...",
|
||||
"settings.updates.toast.latest.title": "最新です",
|
||||
"settings.updates.toast.latest.description": "OpenCode は最新バージョンです。",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "なし",
|
||||
"sound.option.alert01": "アラート 01",
|
||||
"sound.option.alert02": "アラート 02",
|
||||
|
||||
@@ -569,10 +569,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "OpenCode가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요",
|
||||
"settings.general.row.theme.title": "테마",
|
||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||
"settings.general.row.font.title": "코드 글꼴",
|
||||
"settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
|
||||
"settings.general.row.uiFont.title": "UI 글꼴",
|
||||
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
|
||||
"settings.general.row.font.title": "글꼴",
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||
"settings.general.row.followup.title": "후속 조치 동작",
|
||||
"settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
|
||||
"settings.general.row.followup.option.queue": "대기열",
|
||||
@@ -599,6 +597,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "확인 중...",
|
||||
"settings.updates.toast.latest.title": "최신 상태입니다",
|
||||
"settings.updates.toast.latest.description": "현재 최신 버전의 OpenCode를 사용 중입니다.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "없음",
|
||||
"sound.option.alert01": "알림 01",
|
||||
"sound.option.alert02": "알림 02",
|
||||
|
||||
@@ -639,10 +639,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
|
||||
"settings.general.row.font.title": "Kodefont",
|
||||
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
|
||||
"settings.general.row.uiFont.title": "UI-skrift",
|
||||
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
|
||||
"settings.general.row.font.title": "Skrift",
|
||||
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
|
||||
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
||||
"settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
|
||||
"settings.general.row.followup.option.queue": "Kø",
|
||||
@@ -670,6 +668,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Sjekker...",
|
||||
"settings.updates.toast.latest.title": "Du er oppdatert",
|
||||
"settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ingen",
|
||||
"sound.option.alert01": "Varsel 01",
|
||||
"sound.option.alert02": "Varsel 02",
|
||||
|
||||
@@ -570,10 +570,8 @@ export const dict = {
|
||||
"Wybierz, czy OpenCode ma używać motywu systemowego, jasnego czy ciemnego",
|
||||
"settings.general.row.theme.title": "Motyw",
|
||||
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
||||
"settings.general.row.font.title": "Czcionka kodu",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
|
||||
"settings.general.row.uiFont.title": "Czcionka interfejsu",
|
||||
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
|
||||
"settings.general.row.font.title": "Czcionka",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
|
||||
"settings.general.row.followup.title": "Zachowanie kontynuacji",
|
||||
"settings.general.row.followup.description": "Wybierz, czy kontynuacja ma być natychmiastowa, czy czekać w kolejce",
|
||||
"settings.general.row.followup.option.queue": "Kolejka",
|
||||
@@ -600,6 +598,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Sprawdzanie...",
|
||||
"settings.updates.toast.latest.title": "Masz najnowszą wersję",
|
||||
"settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Brak",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
|
||||
@@ -636,10 +636,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Выберите, следует ли OpenCode системной, светлой или тёмной теме",
|
||||
"settings.general.row.theme.title": "Тема",
|
||||
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
|
||||
"settings.general.row.font.title": "Шрифт кода",
|
||||
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
|
||||
"settings.general.row.uiFont.title": "Шрифт интерфейса",
|
||||
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
|
||||
"settings.general.row.font.title": "Шрифт",
|
||||
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
|
||||
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
|
||||
"settings.general.row.followup.description":
|
||||
"Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь",
|
||||
@@ -670,6 +668,19 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Проверка...",
|
||||
"settings.updates.toast.latest.title": "У вас последняя версия",
|
||||
"settings.updates.toast.latest.description": "Вы используете последнюю версию OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Нет",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
|
||||
@@ -630,10 +630,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "เลือกว่าจะให้ OpenCode ใช้ธีมตามระบบ สว่าง หรือมืด",
|
||||
"settings.general.row.theme.title": "ธีม",
|
||||
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
|
||||
"settings.general.row.font.title": "ฟอนต์โค้ด",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
|
||||
"settings.general.row.uiFont.title": "ฟอนต์ UI",
|
||||
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
|
||||
"settings.general.row.font.title": "ฟอนต์",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
|
||||
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
|
||||
"settings.general.row.followup.description": "เลือกว่าจะให้พร้อมท์ติดตามผลทำงานทันทีหรือรอในคิว",
|
||||
"settings.general.row.followup.option.queue": "คิว",
|
||||
@@ -661,6 +659,19 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "คุณเป็นเวอร์ชันล่าสุดแล้ว",
|
||||
"settings.updates.toast.latest.description": "คุณกำลังใช้งาน OpenCode เวอร์ชันล่าสุด",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "ไม่มี",
|
||||
"sound.option.alert01": "เสียงเตือน 01",
|
||||
"sound.option.alert02": "เสียงเตือน 02",
|
||||
|
||||
@@ -643,10 +643,8 @@ export const dict = {
|
||||
"OpenCode'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
|
||||
"settings.general.row.font.title": "Kod Yazı Tipi",
|
||||
"settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
|
||||
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
|
||||
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
|
||||
"settings.general.row.font.title": "Yazı Tipi",
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
|
||||
"settings.general.row.followup.title": "Takip davranışı",
|
||||
"settings.general.row.followup.description":
|
||||
"Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin",
|
||||
@@ -679,6 +677,20 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "Güncelsiniz",
|
||||
"settings.updates.toast.latest.description": "OpenCode'un en son sürümünü kullanıyorsunuz.",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
|
||||
"sound.option.none": "Yok",
|
||||
"sound.option.alert01": "Uyarı 01",
|
||||
"sound.option.alert02": "Uyarı 02",
|
||||
|
||||
@@ -630,10 +630,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "选择 OpenCode 跟随系统、浅色或深色主题",
|
||||
"settings.general.row.theme.title": "主题",
|
||||
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
||||
"settings.general.row.font.title": "代码字体",
|
||||
"settings.general.row.font.description": "自定义代码块和终端使用的字体",
|
||||
"settings.general.row.uiFont.title": "界面字体",
|
||||
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
|
||||
"settings.general.row.font.title": "字体",
|
||||
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
|
||||
"settings.general.row.followup.title": "跟进消息行为",
|
||||
"settings.general.row.followup.description": "选择跟进提示是立即引导还是在队列中等待",
|
||||
"settings.general.row.followup.option.queue": "排队",
|
||||
@@ -659,6 +657,20 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "已是最新版本",
|
||||
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
|
||||
"sound.option.none": "无",
|
||||
"sound.option.alert01": "警报 01",
|
||||
"sound.option.alert02": "警报 02",
|
||||
|
||||
@@ -625,10 +625,8 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "選擇 OpenCode 要跟隨系統、淺色或深色主題",
|
||||
"settings.general.row.theme.title": "主題",
|
||||
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
|
||||
"settings.general.row.font.title": "程式碼字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型",
|
||||
"settings.general.row.uiFont.title": "介面字型",
|
||||
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
|
||||
"settings.general.row.font.title": "字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
|
||||
"settings.general.row.followup.title": "後續追問行為",
|
||||
"settings.general.row.followup.description": "選擇後續追問提示是立即引導還是進入佇列等待",
|
||||
"settings.general.row.followup.option.queue": "佇列",
|
||||
@@ -656,6 +654,19 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "已是最新版本",
|
||||
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"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",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "無",
|
||||
"sound.option.alert01": "警報 01",
|
||||
"sound.option.alert02": "警報 02",
|
||||
|
||||
@@ -12,7 +12,6 @@ import { decode64 } from "@/utils/base64"
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
@@ -23,12 +22,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
void sync.session.sync(id)
|
||||
})
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
|
||||
@@ -26,8 +26,8 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
@@ -57,15 +57,12 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { same } from "@/utils/same"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
const emptyFollowups: FollowupItem[] = []
|
||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
@@ -320,6 +317,7 @@ export default function Page() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const prompt = usePrompt()
|
||||
@@ -515,20 +513,15 @@ export default function Page() {
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||
createStore<{
|
||||
items: Record<string, FollowupItem[] | undefined>
|
||||
failed: Record<string, string | undefined>
|
||||
paused: Record<string, boolean | undefined>
|
||||
edit: Record<string, FollowupEdit | undefined>
|
||||
}>({
|
||||
items: {},
|
||||
failed: {},
|
||||
paused: {},
|
||||
edit: {},
|
||||
}),
|
||||
)
|
||||
const [followup, setFollowup] = createStore({
|
||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||
failed: {} as Record<string, string | undefined>,
|
||||
paused: {} as Record<string, boolean | undefined>,
|
||||
edit: {} as Record<
|
||||
string,
|
||||
{ id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined
|
||||
>,
|
||||
})
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = sessionKey()
|
||||
@@ -719,6 +712,7 @@ export default function Page() {
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
|
||||
|
||||
untrack(() => {
|
||||
void sync.session.sync(id)
|
||||
})
|
||||
@@ -1562,6 +1556,26 @@ export default function Page() {
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const fork = (input: { sessionID: string; messageID: string }) => {
|
||||
const value = draft(input.messageID)
|
||||
const dir = base64Encode(sdk.directory)
|
||||
return sdk.client.session
|
||||
.fork(input)
|
||||
.then((result) => {
|
||||
const next = result.data
|
||||
if (!next) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
prompt.set(value, undefined, { dir, id: next.id })
|
||||
navigate(`/${dir}/session/${next.id}`)
|
||||
})
|
||||
.catch(fail)
|
||||
}
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
if (reverting()) return
|
||||
return revertMutation.mutateAsync(input)
|
||||
@@ -1580,7 +1594,7 @@ export default function Page() {
|
||||
.map((item) => ({ id: item.id, text: line(item.id) }))
|
||||
})
|
||||
|
||||
const actions = { revert }
|
||||
const actions = { fork, revert }
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
@@ -1640,15 +1654,6 @@ export default function Page() {
|
||||
consumePendingMessage: layout.pendingMessage.consume,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id) => {
|
||||
if (!id) requestAnimationFrame(() => inputRef?.focus())
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
@@ -943,10 +943,7 @@ export function MessageTimeline(props: {
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
style={{
|
||||
"content-visibility": active() ? undefined : "auto",
|
||||
"contain-intrinsic-size": active() ? undefined : "auto 500px",
|
||||
}}
|
||||
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
@@ -292,11 +293,9 @@ export function SessionSidePanel(props: {
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}}
|
||||
onClick={() =>
|
||||
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -11,6 +11,10 @@ import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
import { DialogFork } from "@/components/dialog-fork"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
@@ -253,11 +257,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
},
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
@@ -351,11 +351,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
},
|
||||
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
@@ -363,11 +359,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
},
|
||||
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
@@ -495,11 +487,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
},
|
||||
onSelect: () => dialog.show(() => <DialogFork />),
|
||||
}),
|
||||
...share,
|
||||
]
|
||||
|
||||
@@ -3,14 +3,6 @@ type ModelKey = {
|
||||
modelID: string
|
||||
}
|
||||
|
||||
type ModelItem = ModelKey & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type AgentItem = {
|
||||
name: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
agent?: string
|
||||
model?: ModelKey | null
|
||||
@@ -34,9 +26,6 @@ export type ModelProbeState = {
|
||||
pick?: State
|
||||
base?: State
|
||||
current?: string
|
||||
variants?: string[]
|
||||
models?: ModelItem[]
|
||||
agents?: AgentItem[]
|
||||
}
|
||||
|
||||
export type ModelWindow = Window & {
|
||||
@@ -44,11 +33,6 @@ export type ModelWindow = Window & {
|
||||
model?: {
|
||||
enabled?: boolean
|
||||
current?: ModelProbeState
|
||||
controls?: {
|
||||
setAgent?: (name: string | undefined) => void
|
||||
setModel?: (value: ModelKey | undefined) => void
|
||||
setVariant?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,8 +45,6 @@ const clone = (state?: State) => {
|
||||
}
|
||||
}
|
||||
|
||||
let active: symbol | undefined
|
||||
|
||||
export const modelEnabled = () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return (window as ModelWindow).__opencode_e2e?.model?.enabled === true
|
||||
@@ -74,15 +56,9 @@ const root = () => {
|
||||
}
|
||||
|
||||
export const modelProbe = {
|
||||
bind(id: symbol, input: NonNullable<NonNullable<ModelWindow["__opencode_e2e"]>["model"]>["controls"]) {
|
||||
set(input: ModelProbeState) {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
active = id
|
||||
state.controls = input
|
||||
},
|
||||
set(id: symbol, input: ModelProbeState) {
|
||||
const state = root()
|
||||
if (!state || active !== id) return
|
||||
state.current = {
|
||||
...input,
|
||||
model: input.model ? { ...input.model } : undefined,
|
||||
@@ -94,16 +70,11 @@ export const modelProbe = {
|
||||
: undefined,
|
||||
pick: clone(input.pick),
|
||||
base: clone(input.base),
|
||||
variants: input.variants?.slice(),
|
||||
models: input.models?.map((item) => ({ ...item })),
|
||||
agents: input.agents?.map((item) => ({ ...item })),
|
||||
}
|
||||
},
|
||||
clear(id: symbol) {
|
||||
clear() {
|
||||
const state = root()
|
||||
if (!state || active !== id) return
|
||||
active = undefined
|
||||
if (!state) return
|
||||
state.current = undefined
|
||||
state.controls = undefined
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"@typescript/native-preview": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
"@ai-sdk/openai-compatible": "1.0.1",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
"@opencode-ai/console-resource": "workspace:*",
|
||||
|
||||
@@ -9,6 +9,3 @@ Here's the process I've been using to create icons:
|
||||
|
||||
The Image2Icon step is necessary as the `icon.icns` generated by `app-icon.png` does not apply the shadow/padding expected by macOS,
|
||||
so app icons appear larger than expected.
|
||||
|
||||
For unpackaged Electron on macOS, `app.dock.setIcon()` should use a PNG. Keep `dock.png` in each channel folder synced with the
|
||||
extracted `icon_128x128@2x.png` from that channel's `icon.icns` so the dev Dock icon matches the packaged app inset.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -50,8 +50,7 @@ export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> =
|
||||
|
||||
export function setDockIcon() {
|
||||
if (process.platform !== "darwin") return
|
||||
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
|
||||
if (!icon.isEmpty()) app.dock?.setIcon(icon)
|
||||
app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "128x128@2x.png")))
|
||||
}
|
||||
|
||||
export function createMainWindow(globals: Globals) {
|
||||
|
||||
@@ -2,5 +2,4 @@ research
|
||||
dist
|
||||
gen
|
||||
app.log
|
||||
src/provider/models-snapshot.js
|
||||
src/provider/models-snapshot.d.ts
|
||||
src/provider/models-snapshot.ts
|
||||
|
||||
@@ -49,10 +49,6 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
|
||||
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
## Effect.cached for deduplication
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
|
||||
|
||||
## Instance.bind — ALS for native callbacks
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["@opentui/solid/preload", "./test/preload.ts"]
|
||||
preload = ["./test/preload.ts"]
|
||||
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
|
||||
# using --timeout in package.json scripts instead
|
||||
# https://github.com/oven-sh/bun/issues/7789
|
||||
|
||||
@@ -68,25 +68,25 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.80",
|
||||
"@ai-sdk/google": "3.0.53",
|
||||
"@ai-sdk/google-vertex": "4.0.95",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.21",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.74",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.82",
|
||||
"@ai-sdk/anthropic": "2.0.65",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.36",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.54",
|
||||
"@ai-sdk/google-vertex": "3.0.106",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
"@ai-sdk/openai-compatible": "1.0.32",
|
||||
"@ai-sdk/perplexity": "2.0.23",
|
||||
"@ai-sdk/provider": "2.0.1",
|
||||
"@ai-sdk/provider-utils": "3.0.21",
|
||||
"@ai-sdk/togetherai": "1.0.34",
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -100,7 +100,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -110,7 +110,7 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
@@ -121,7 +121,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.0.0",
|
||||
"gitlab-ai-provider": "5.3.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -63,30 +63,22 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||
|
||||
const createEmbeddedWebUIBundle = async () => {
|
||||
console.log(`Building Web UI to embed in the binary`)
|
||||
const appDir = path.join(import.meta.dirname, "../../app")
|
||||
const dist = path.join(appDir, "dist")
|
||||
await $`bun run --cwd ${appDir} build`
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
|
||||
.map((file) => file.replaceAll("\\", "/"))
|
||||
.sort()
|
||||
const imports = files.map((file, i) => {
|
||||
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
|
||||
return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };`
|
||||
})
|
||||
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`)
|
||||
return [
|
||||
`// Import all files as file_$i with type: "file"`,
|
||||
...imports,
|
||||
`// Export with original mappings`,
|
||||
`export default {`,
|
||||
...entries,
|
||||
`}`,
|
||||
].join("\n")
|
||||
const allFiles = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: path.join(appDir, "dist") }))
|
||||
const fileMap = `
|
||||
// Import all files as file_$i with type: "file"
|
||||
${allFiles.map((filePath, i) => `import file_${i} from "${path.join(appDir, "dist", filePath)}" with { type: "file" };`).join("\n")}
|
||||
// Export with original mappings
|
||||
export default {
|
||||
${allFiles.map((filePath, i) => `"${filePath}": file_${i},`).join("\n")}
|
||||
}
|
||||
`.trim()
|
||||
return fileMap
|
||||
}
|
||||
|
||||
const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
|
||||
@@ -208,7 +200,7 @@ for (const item of targets) {
|
||||
await Bun.build({
|
||||
conditions: ["browser"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
plugins: [solidPlugin],
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
|
||||
@@ -8,8 +8,8 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
|
||||
|
||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||
|
||||
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
|
||||
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
@@ -121,32 +121,6 @@ yield *
|
||||
|
||||
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
|
||||
|
||||
## Effect.cached for deduplication
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
|
||||
|
||||
```ts
|
||||
// Inside the layer — yield* to initialize the memo
|
||||
let cached = yield * Effect.cached(loadExpensive())
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* () {
|
||||
return yield* cached // concurrent callers share the same fiber
|
||||
})
|
||||
|
||||
// To invalidate: swap in a fresh memo
|
||||
const invalidate = Effect.fn("Foo.invalidate")(function* () {
|
||||
cached = yield* Effect.cached(loadExpensive())
|
||||
})
|
||||
```
|
||||
|
||||
Prefer `Effect.cached` over these patterns:
|
||||
|
||||
- Storing a `Fiber.Fiber | undefined` with manual check-and-fork (e.g. `file/index.ts` `ensure`)
|
||||
- Storing a `Promise<void>` task for deduplication (e.g. `skill/index.ts` `ensure`)
|
||||
- `let cached: X | undefined` with check-and-load (races when two callers see `undefined` before either resolves)
|
||||
|
||||
`Effect.cached` handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with `yield* Effect.cached(...)` — the old memo is discarded.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
@@ -181,112 +155,36 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Agent` — `agent/agent.ts`
|
||||
- [x] `AppFileSystem` — `filesystem/index.ts`
|
||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||
- [x] `Bus` — `bus/index.ts`
|
||||
- [x] `Command` — `command/index.ts`
|
||||
- [x] `Config` — `config/config.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileTime` — `file/time.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
- [x] `LSP` — `lsp/index.ts`
|
||||
- [x] `MCP` — `mcp/index.ts`
|
||||
- [x] `McpAuth` — `mcp/auth.ts`
|
||||
- [x] `Permission` — `permission/index.ts`
|
||||
- [x] `Plugin` — `plugin/index.ts`
|
||||
- [x] `Project` — `project/project.ts`
|
||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||
- [x] `Pty` — `pty/index.ts`
|
||||
- [x] `Question` — `question/index.ts`
|
||||
- [x] `SessionStatus` — `session/status.ts`
|
||||
- [x] `Skill` — `skill/index.ts`
|
||||
- [x] `Snapshot` — `snapshot/index.ts`
|
||||
- [x] `ToolRegistry` — `tool/registry.ts`
|
||||
- [x] `Truncate` — `tool/truncate.ts`
|
||||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Worktree` — `worktree/index.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts`
|
||||
- [x] `SessionStatus`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [x] `Session` — `session/index.ts`
|
||||
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
|
||||
|
||||
Other services not yet migrated:
|
||||
|
||||
- [ ] `SessionSummary` — `session/summary.ts`
|
||||
- [ ] `SessionTodo` — `session/todo.ts`
|
||||
- [ ] `SessionRevert` — `session/revert.ts`
|
||||
- [ ] `Instruction` — `session/instruction.ts`
|
||||
- [ ] `ShareNext` — `share/share-next.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts`
|
||||
- [ ] `Storage` — `storage/storage.ts`
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||
|
||||
## Tool interface → Effect
|
||||
|
||||
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
|
||||
|
||||
1. Migrate each tool to return Effects
|
||||
2. Update `Tool.define()` factory to work with Effects
|
||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
|
||||
|
||||
Individual tools, ordered by value:
|
||||
|
||||
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
||||
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
|
||||
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
|
||||
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
|
||||
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
||||
- [ ] `task.ts` — MEDIUM: task state management
|
||||
- [ ] `glob.ts` — LOW: simple async generator
|
||||
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||
- [ ] `skill.ts` — LOW: skill tool adapter
|
||||
- [ ] `plan.ts` — LOW: plan file operations
|
||||
|
||||
## Effect service adoption in already-migrated code
|
||||
|
||||
Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
|
||||
|
||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||
|
||||
- [ ] `file/index.ts` — 11 calls (the File service itself)
|
||||
- [ ] `config/config.ts` — 7 calls
|
||||
- [ ] `auth/index.ts` — 3 calls
|
||||
- [ ] `skill/index.ts` — 3 calls
|
||||
- [ ] `file/time.ts` — 1 call
|
||||
|
||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||
|
||||
- [ ] `format/index.ts` — 1 call
|
||||
|
||||
## Filesystem consolidation
|
||||
|
||||
`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
||||
|
||||
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||
|
||||
Current raw fs users that will convert during tool migration:
|
||||
- `tool/read.ts` — fs.createReadStream, readline
|
||||
- `tool/apply_patch.ts` — fs/promises
|
||||
- `tool/bash.ts` — fs/promises
|
||||
- `file/ripgrep.ts` — fs/promises
|
||||
- `storage/storage.ts` — fs/promises
|
||||
- `patch/index.ts` — fs, fs/promises
|
||||
|
||||
## Primitives & utilities
|
||||
|
||||
- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
|
||||
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
|
||||
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
|
||||
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [x] `Worktree`
|
||||
- [x] `Bus`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [x] `Project`
|
||||
- [x] `LSP`
|
||||
- [x] `MCP`
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
# TUI plugins
|
||||
|
||||
Technical reference for the current TUI plugin system.
|
||||
|
||||
## Overview
|
||||
|
||||
- TUI plugin config lives in `tui.json`.
|
||||
- Author package entrypoint is `@opencode-ai/plugin/tui`.
|
||||
- Internal plugins load inside the CLI app the same way external TUI plugins do.
|
||||
- Package plugins can be installed from CLI or TUI.
|
||||
|
||||
## TUI config
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "smoke-theme",
|
||||
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
|
||||
"plugin_enabled": {
|
||||
"acme.demo": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `plugin` entries can be either a string spec or `[spec, options]`.
|
||||
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
|
||||
- Relative path specs are resolved relative to the config file that declared them.
|
||||
- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
|
||||
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
|
||||
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
|
||||
- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted.
|
||||
- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`.
|
||||
- `plugin_enabled` is merged across config layers.
|
||||
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
|
||||
|
||||
## Author package shape
|
||||
|
||||
Package entrypoint:
|
||||
|
||||
- Import types from `@opencode-ai/plugin/tui`.
|
||||
- `@opencode-ai/plugin` exports `./tui` and declares optional peer deps on `@opentui/core` and `@opentui/solid`.
|
||||
|
||||
Minimal module shape:
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tui: TuiPlugin = async (api, options, meta) => {
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: "Demo",
|
||||
value: "demo.open",
|
||||
onSelect: () => api.route.navigate("demo"),
|
||||
},
|
||||
])
|
||||
|
||||
api.route.register([
|
||||
{
|
||||
name: "demo",
|
||||
render: () => (
|
||||
<box>
|
||||
<text>demo</text>
|
||||
</box>
|
||||
),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "acme.demo",
|
||||
tui,
|
||||
}
|
||||
```
|
||||
|
||||
- Loader only reads the module default export object. Named exports are ignored.
|
||||
- TUI shape is `default export { id?, tui }`.
|
||||
- `tui` signature is `(api, options, meta) => Promise<void>`.
|
||||
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
|
||||
- File/path plugins must export a non-empty `id`.
|
||||
- npm plugins may omit `id`; package `name` is used.
|
||||
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
|
||||
- If a path spec points at a directory, that directory must have `package.json` with `main`.
|
||||
- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
|
||||
|
||||
## Package manifest and install
|
||||
|
||||
Package manifest is read from `package.json` field `oc-plugin`.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@acme/opencode-plugin",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"engines": {
|
||||
"opencode": "^1.0.0"
|
||||
},
|
||||
"oc-plugin": [
|
||||
["server", { "custom": true }],
|
||||
["tui", { "compact": true }]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Version compatibility
|
||||
|
||||
npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"engines": {
|
||||
"opencode": "^1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The value is a semver range checked against the running OpenCode version.
|
||||
- If the range is not satisfied, the plugin is skipped with a warning and a session error.
|
||||
- If `engines.opencode` is absent, no check is performed (backward compatible).
|
||||
- File plugins are never checked; only npm package plugins are validated.
|
||||
|
||||
- Install flow is shared by CLI and TUI in `src/plugin/install.ts`.
|
||||
- Shared helpers are `installPlugin`, `readPluginManifest`, and `patchPluginConfig`.
|
||||
- `opencode plugin <module>` and TUI install both run install → manifest read → config patch.
|
||||
- Alias: `opencode plug <module>`.
|
||||
- `-g` / `--global` writes into the global config dir.
|
||||
- Local installs resolve target dir inside `patchPluginConfig`.
|
||||
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
||||
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
||||
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
|
||||
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
||||
- Without `--force`, an already-configured npm package name is a no-op.
|
||||
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||
- A package can target `server`, `tui`, or both.
|
||||
- There is no uninstall, list, or update CLI command for external plugins.
|
||||
- Local file plugins are configured directly in `tui.json`.
|
||||
|
||||
When `plugin` entries exist in a writable `.opencode` dir or `OPENCODE_CONFIG_DIR`, OpenCode installs `@opencode-ai/plugin` into that dir and writes:
|
||||
|
||||
- `package.json`
|
||||
- `bun.lock`
|
||||
- `node_modules/`
|
||||
- `.gitignore`
|
||||
|
||||
That is what makes local config-scoped plugins able to import `@opencode-ai/plugin/tui`.
|
||||
|
||||
## TUI plugin API
|
||||
|
||||
Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
|
||||
- `api.app.version`
|
||||
- `api.command.register(cb)` / `api.command.trigger(value)`
|
||||
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
|
||||
- `api.keybind.match`, `print`, `create`
|
||||
- `api.tuiConfig`
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
- `api.state`
|
||||
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
|
||||
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
|
||||
- `api.event.on(type, handler)`
|
||||
- `api.renderer`
|
||||
- `api.slots.register(plugin)`
|
||||
- `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)`
|
||||
- `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)`
|
||||
|
||||
### Commands
|
||||
|
||||
`api.command.register` returns an unregister function. Command rows support:
|
||||
|
||||
- `title`, `value`
|
||||
- `description`, `category`
|
||||
- `keybind`
|
||||
- `suggested`, `hidden`, `enabled`
|
||||
- `slash: { name, aliases? }`
|
||||
- `onSelect`
|
||||
|
||||
Command behavior:
|
||||
|
||||
- Registrations are reactive.
|
||||
- Later registrations win for duplicate `value` and for keybind handling.
|
||||
- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`.
|
||||
|
||||
### Routes
|
||||
|
||||
- Reserved route names: `home` and `session`.
|
||||
- Any other name is treated as a plugin route.
|
||||
- `api.route.current` returns one of:
|
||||
- `{ name: "home" }`
|
||||
- `{ name: "session", params: { sessionID, initialPrompt? } }`
|
||||
- `{ name: string, params?: Record<string, unknown> }`
|
||||
- `api.route.navigate("session", params)` only uses `params.sessionID`. It cannot set `initialPrompt`.
|
||||
- If multiple plugins register the same route name, the last registered route wins.
|
||||
- Unknown plugin routes render a fallback screen with a `go home` action.
|
||||
|
||||
### Dialogs and toast
|
||||
|
||||
- `ui.Dialog` is the base dialog wrapper.
|
||||
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
|
||||
- `ui.toast(...)` shows a toast.
|
||||
- `ui.dialog` exposes the host dialog stack:
|
||||
- `replace(render, onClose?)`
|
||||
- `clear()`
|
||||
- `setSize("medium" | "large" | "xlarge")`
|
||||
- readonly `size`, `depth`, `open`
|
||||
|
||||
### Keybinds
|
||||
|
||||
- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer.
|
||||
- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set.
|
||||
- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
|
||||
- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`.
|
||||
|
||||
### KV, state, client, events
|
||||
|
||||
- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.
|
||||
- `api.kv` exposes `ready`.
|
||||
- `api.tuiConfig` and `api.state` are live host objects/getters, not frozen snapshots.
|
||||
- `api.state` exposes synced TUI state:
|
||||
- `ready`
|
||||
- `config`
|
||||
- `provider`
|
||||
- `path.{state,config,worktree,directory}`
|
||||
- `vcs?.branch`
|
||||
- `workspace.list()` / `workspace.get(workspaceID)`
|
||||
- `session.count()`
|
||||
- `session.diff(sessionID)`
|
||||
- `session.todo(sessionID)`
|
||||
- `session.messages(sessionID)`
|
||||
- `session.status(sessionID)`
|
||||
- `session.permission(sessionID)`
|
||||
- `session.question(sessionID)`
|
||||
- `part(messageID)`
|
||||
- `lsp()`
|
||||
- `mcp()`
|
||||
- `api.client` always reflects the current runtime client.
|
||||
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
|
||||
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
|
||||
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
|
||||
- `api.renderer` exposes the raw `CliRenderer`.
|
||||
|
||||
### Theme
|
||||
|
||||
- `api.theme.current` exposes the resolved current theme tokens.
|
||||
- `api.theme.selected` is the selected theme name.
|
||||
- `api.theme.has(name)` checks for an installed theme.
|
||||
- `api.theme.set(name)` switches theme and returns `boolean`.
|
||||
- `api.theme.mode()` returns `"dark" | "light"`.
|
||||
- `api.theme.install(jsonPath)` installs a theme JSON file.
|
||||
- `api.theme.ready` reports theme readiness.
|
||||
|
||||
Theme install behavior:
|
||||
|
||||
- Relative theme paths are resolved from the plugin root.
|
||||
- Theme name is the JSON basename.
|
||||
- Install is skipped if that theme name already exists.
|
||||
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
|
||||
- Global plugins persist installed themes under the global `themes` dir.
|
||||
- Invalid or unreadable theme files are ignored.
|
||||
|
||||
### Slots
|
||||
|
||||
Current host slot names:
|
||||
|
||||
- `app`
|
||||
- `home_logo`
|
||||
- `home_bottom`
|
||||
- `sidebar_title` with props `{ session_id, title, share_url? }`
|
||||
- `sidebar_content` with props `{ session_id }`
|
||||
- `sidebar_footer` with props `{ session_id }`
|
||||
|
||||
Slot notes:
|
||||
|
||||
- Slot context currently exposes only `theme`.
|
||||
- `api.slots.register(plugin)` returns the host-assigned slot plugin id.
|
||||
- `api.slots.register(plugin)` does not return an unregister function.
|
||||
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
||||
- Plugin-provided `id` is not allowed.
|
||||
- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||
- Plugins cannot define new slot names in this branch.
|
||||
|
||||
### Plugin control and lifecycle
|
||||
|
||||
- `api.plugins.list()` returns `{ id, source, spec, target, enabled, active }[]`.
|
||||
- `enabled` is the persisted desired state. `active` means the plugin is currently initialized.
|
||||
- `api.plugins.activate(id)` sets `enabled=true`, persists it into KV, and initializes the plugin.
|
||||
- `api.plugins.deactivate(id)` sets `enabled=false`, persists it into KV, and disposes the plugin scope.
|
||||
- `api.plugins.add(spec)` trims the input and returns `false` for an empty string.
|
||||
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
|
||||
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
|
||||
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
|
||||
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
||||
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
||||
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
||||
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
|
||||
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
||||
- `api.lifecycle.signal` is aborted before cleanup runs.
|
||||
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
||||
|
||||
## Plugin metadata
|
||||
|
||||
`meta` passed to `tui(api, options, meta)` contains:
|
||||
|
||||
- `state`: `first | updated | same`
|
||||
- `id`, `source`, `spec`, `target`
|
||||
- npm-only fields when available: `requested`, `version`
|
||||
- file-only field when available: `modified`
|
||||
- `first_time`, `last_time`, `time_changed`, `load_count`, `fingerprint`
|
||||
|
||||
Metadata is persisted by plugin id.
|
||||
|
||||
- File plugin fingerprint is `target|modified`.
|
||||
- npm plugin fingerprint is `target|requested|version`.
|
||||
- Internal plugins get synthetic metadata with `state: "same"`.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
- Internal TUI plugins load first.
|
||||
- External TUI plugins load from `tuiConfig.plugin`.
|
||||
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
|
||||
- External plugin resolution and import are parallel.
|
||||
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
|
||||
- File plugins that fail initially are retried once after waiting for config dependency installation.
|
||||
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
|
||||
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
|
||||
- Runtime install and runtime add are separate operations.
|
||||
- Plugin init failure rolls back that plugin's tracked registrations and loading continues.
|
||||
- TUI runtime tracks and disposes:
|
||||
- command registrations
|
||||
- route registrations
|
||||
- event subscriptions
|
||||
- slot registrations
|
||||
- explicit `lifecycle.onDispose(...)` handlers
|
||||
- Cleanup runs in reverse order.
|
||||
- Cleanup is awaited.
|
||||
- Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues.
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
- `internal:home-tips`
|
||||
- `internal:sidebar-context`
|
||||
- `internal:sidebar-mcp`
|
||||
- `internal:sidebar-lsp`
|
||||
- `internal:sidebar-todo`
|
||||
- `internal:sidebar-files`
|
||||
- `internal:sidebar-footer`
|
||||
- `internal:plugin-manager`
|
||||
|
||||
Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`.
|
||||
|
||||
The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`.
|
||||
|
||||
- Keybind name is `plugin_manager`.
|
||||
- Default keybind is `none`.
|
||||
- It lists both internal and external plugins.
|
||||
- It toggles based on `active`.
|
||||
- Its own row is disabled only inside the manager dialog.
|
||||
- It also exposes command `plugins.install` with title `Install plugin`.
|
||||
- Inside the Plugins dialog, key `shift+i` opens the install prompt.
|
||||
- Install prompt asks for npm package name.
|
||||
- Scope defaults to local, and `tab` toggles local/global.
|
||||
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
|
||||
- Manager install uses `api.plugins.install(spec, { global })`.
|
||||
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
|
||||
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
|
||||
- If runtime add fails, TUI shows a warning and restart remains the fallback.
|
||||
|
||||
## Current in-repo examples
|
||||
|
||||
- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx`
|
||||
- Local smoke config: `.opencode/tui.json`
|
||||
- Local smoke theme: `.opencode/plugins/smoke-theme.json`
|
||||
@@ -72,14 +72,13 @@ export namespace Agent {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const config = () => Effect.promise(() => Config.get())
|
||||
const auth = yield* Auth.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const cfg = yield* config()
|
||||
const skillDirs = yield* Effect.promise(() => Skill.dirs())
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
@@ -282,7 +281,7 @@ export namespace Agent {
|
||||
})
|
||||
|
||||
const list = Effect.fnUntraced(function* () {
|
||||
const cfg = yield* config.get()
|
||||
const cfg = yield* config()
|
||||
return pipe(
|
||||
agents,
|
||||
values(),
|
||||
@@ -294,7 +293,7 @@ export namespace Agent {
|
||||
})
|
||||
|
||||
const defaultAgent = Effect.fnUntraced(function* () {
|
||||
const c = yield* config.get()
|
||||
const c = yield* config()
|
||||
if (c.default_agent) {
|
||||
const agent = agents[c.default_agent]
|
||||
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
|
||||
@@ -329,7 +328,7 @@ export namespace Agent {
|
||||
description: string
|
||||
model?: { providerID: ProviderID; modelID: ModelID }
|
||||
}) {
|
||||
const cfg = yield* config.get()
|
||||
const cfg = yield* config()
|
||||
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||
@@ -392,11 +391,7 @@ export namespace Agent {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Auth.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { online, proxied } from "@/util/network"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
@@ -68,13 +68,12 @@ export namespace BunProc {
|
||||
|
||||
if (!modExists || !cachedVersion) {
|
||||
// continue to install
|
||||
} else if (version === "latest") {
|
||||
if (!online()) return mod
|
||||
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!stale) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
} else if (cachedVersion === version) {
|
||||
} else if (version !== "latest" && cachedVersion === version) {
|
||||
return mod
|
||||
} else if (version === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!isOutdated) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import semver from "semver"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { online } from "@/util/network"
|
||||
|
||||
export namespace PackageRegistry {
|
||||
const log = Log.create({ service: "bun" })
|
||||
@@ -11,11 +10,6 @@ export namespace PackageRegistry {
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
if (!online()) {
|
||||
log.debug("offline, skipping bun info", { pkg, field })
|
||||
return null
|
||||
}
|
||||
|
||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
env: {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { JsonMigration } from "../../storage/json-migration"
|
||||
import { EOL } from "os"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
const QueryCommand = cmd({
|
||||
command: "$0 [query]",
|
||||
@@ -40,7 +39,7 @@ const QueryCommand = cmd({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
UI.error(errorMessage(err))
|
||||
UI.error(err instanceof Error ? err.message : String(err))
|
||||
process.exit(1)
|
||||
}
|
||||
db.close()
|
||||
@@ -101,7 +100,7 @@ const MigrateCommand = cmd({
|
||||
}
|
||||
} catch (err) {
|
||||
if (tty) process.stderr.write("\x1b[?25h")
|
||||
UI.error(`Migration failed: ${errorMessage(err)}`)
|
||||
UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
sqlite.close()
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||
import type { Argv } from "yargs"
|
||||
|
||||
import { ConfigPaths } from "../../config/paths"
|
||||
import { Global } from "../../global"
|
||||
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||
import { resolvePluginTarget } from "../../plugin/shared"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Process } from "../../util/process"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
type Spin = {
|
||||
start: (msg: string) => void
|
||||
stop: (msg: string, code?: number) => void
|
||||
}
|
||||
|
||||
export type PlugDeps = {
|
||||
spinner: () => Spin
|
||||
log: {
|
||||
error: (msg: string) => void
|
||||
info: (msg: string) => void
|
||||
success: (msg: string) => void
|
||||
}
|
||||
resolve: (spec: string) => Promise<string>
|
||||
readText: (file: string) => Promise<string>
|
||||
write: (file: string, text: string) => Promise<void>
|
||||
exists: (file: string) => Promise<boolean>
|
||||
files: (dir: string, name: "opencode" | "tui") => string[]
|
||||
global: string
|
||||
}
|
||||
|
||||
export type PlugInput = {
|
||||
mod: string
|
||||
global?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export type PlugCtx = {
|
||||
vcs?: string
|
||||
worktree: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
const defaultPlugDeps: PlugDeps = {
|
||||
spinner: () => spinner(),
|
||||
log: {
|
||||
error: (msg) => log.error(msg),
|
||||
info: (msg) => log.info(msg),
|
||||
success: (msg) => log.success(msg),
|
||||
},
|
||||
resolve: (spec) => resolvePluginTarget(spec),
|
||||
readText: (file) => Filesystem.readText(file),
|
||||
write: async (file, text) => {
|
||||
await Filesystem.write(file, text)
|
||||
},
|
||||
exists: (file) => Filesystem.exists(file),
|
||||
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
|
||||
global: Global.Path.config,
|
||||
}
|
||||
|
||||
function cause(err: unknown) {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("cause" in err)) return
|
||||
return (err as { cause?: unknown }).cause
|
||||
}
|
||||
|
||||
export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
|
||||
const mod = input.mod
|
||||
const force = Boolean(input.force)
|
||||
const global = Boolean(input.global)
|
||||
|
||||
return async (ctx: PlugCtx) => {
|
||||
const install = dep.spinner()
|
||||
install.start("Installing plugin package...")
|
||||
const target = await installPlugin(mod, dep)
|
||||
if (!target.ok) {
|
||||
install.stop("Install failed", 1)
|
||||
dep.log.error(`Could not install "${mod}"`)
|
||||
const hit = cause(target.error) ?? target.error
|
||||
if (hit instanceof Process.RunFailedError) {
|
||||
const lines = hit.stderr
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
||||
const detail = errs[0] ?? lines.at(-1)
|
||||
if (detail) dep.log.error(detail)
|
||||
if (lines.some((line) => line.includes("No version matching"))) {
|
||||
dep.log.info("This package depends on a version that is not available in your npm registry.")
|
||||
dep.log.info("Check npm registry/auth settings and try again.")
|
||||
}
|
||||
}
|
||||
if (!(hit instanceof Process.RunFailedError)) {
|
||||
dep.log.error(errorMessage(hit))
|
||||
}
|
||||
return false
|
||||
}
|
||||
install.stop("Plugin package ready")
|
||||
|
||||
const inspect = dep.spinner()
|
||||
inspect.start("Reading plugin manifest...")
|
||||
const manifest = await readPluginManifest(target.target)
|
||||
if (!manifest.ok) {
|
||||
if (manifest.code === "manifest_read_failed") {
|
||||
inspect.stop("Manifest read failed", 1)
|
||||
dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
|
||||
dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
|
||||
return false
|
||||
}
|
||||
|
||||
if (manifest.code === "manifest_no_targets") {
|
||||
inspect.stop("No plugin targets found", 1)
|
||||
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
|
||||
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
|
||||
return false
|
||||
}
|
||||
|
||||
inspect.stop("Manifest read failed", 1)
|
||||
return false
|
||||
}
|
||||
|
||||
inspect.stop(
|
||||
`Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
|
||||
)
|
||||
|
||||
const patch = dep.spinner()
|
||||
patch.start("Updating plugin config...")
|
||||
const out = await patchPluginConfig(
|
||||
{
|
||||
spec: mod,
|
||||
targets: manifest.targets,
|
||||
force,
|
||||
global,
|
||||
vcs: ctx.vcs,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
config: dep.global,
|
||||
},
|
||||
dep,
|
||||
)
|
||||
if (!out.ok) {
|
||||
if (out.code === "invalid_json") {
|
||||
patch.stop(`Failed updating ${out.kind} config`, 1)
|
||||
dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
|
||||
dep.log.info("Fix the config file and run the command again.")
|
||||
return false
|
||||
}
|
||||
|
||||
patch.stop("Failed updating plugin config", 1)
|
||||
dep.log.error(errorMessage(out.error))
|
||||
return false
|
||||
}
|
||||
patch.stop("Plugin config updated")
|
||||
for (const item of out.items) {
|
||||
if (item.mode === "noop") {
|
||||
dep.log.info(`Already configured in ${item.file}`)
|
||||
continue
|
||||
}
|
||||
if (item.mode === "replace") {
|
||||
dep.log.info(`Replaced in ${item.file}`)
|
||||
continue
|
||||
}
|
||||
dep.log.info(`Added to ${item.file}`)
|
||||
}
|
||||
|
||||
dep.log.success(`Installed ${mod}`)
|
||||
dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const PluginCommand = cmd({
|
||||
command: "plugin <module>",
|
||||
aliases: ["plug"],
|
||||
describe: "install plugin and update config",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("module", {
|
||||
type: "string",
|
||||
describe: "npm module name",
|
||||
})
|
||||
.option("global", {
|
||||
alias: ["g"],
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "install in global config",
|
||||
})
|
||||
.option("force", {
|
||||
alias: ["f"],
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "replace existing plugin version",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const mod = String(args.module ?? "").trim()
|
||||
if (!mod) {
|
||||
UI.error("module is required")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
intro(`Install plugin ${mod}`)
|
||||
|
||||
const run = createPlugTask({
|
||||
mod,
|
||||
global: Boolean(args.global),
|
||||
force: Boolean(args.force),
|
||||
})
|
||||
let ok = true
|
||||
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
fn: async () => {
|
||||
ok = await run({
|
||||
vcs: Instance.project.vcs,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
outro("Done")
|
||||
if (!ok) process.exitCode = 1
|
||||
},
|
||||
})
|
||||
@@ -1,30 +1,15 @@
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
createEffect,
|
||||
createMemo,
|
||||
ErrorBoundary,
|
||||
createSignal,
|
||||
onMount,
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
onCleanup,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import semver from "semver"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
||||
@@ -36,7 +21,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { KeybindProvider } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
@@ -55,10 +40,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfigProvider } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
@@ -121,42 +104,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
}
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
return {
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const formatted = FormatError(error)
|
||||
if (formatted !== undefined) return formatted
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"data" in error &&
|
||||
typeof error.data === "object" &&
|
||||
error.data !== null &&
|
||||
"message" in error.data &&
|
||||
typeof error.data.message === "string"
|
||||
) {
|
||||
return error.data.message
|
||||
}
|
||||
return FormatUnknownError(error)
|
||||
}
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
@@ -184,68 +132,77 @@ export function tui(input: {
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onBeforeExit = async () => {
|
||||
await TuiPluginRuntime.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
|
||||
)}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
render(
|
||||
() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
},
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const tuiConfig = useTuiConfig()
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
@@ -254,47 +211,12 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
|
||||
const sync = useSync()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const routes: RouteMap = new Map()
|
||||
const [routeRev, setRouteRev] = createSignal(0)
|
||||
const routeView = (name: string) => {
|
||||
routeRev()
|
||||
return routes.get(name)?.at(-1)?.render
|
||||
}
|
||||
|
||||
const api = createTuiApi({
|
||||
command,
|
||||
tuiConfig,
|
||||
dialog,
|
||||
keybind,
|
||||
kv,
|
||||
route,
|
||||
routes,
|
||||
bump: () => setRouteRev((x) => x + 1),
|
||||
sdk,
|
||||
sync,
|
||||
theme: themeState,
|
||||
toast,
|
||||
renderer,
|
||||
})
|
||||
onCleanup(() => {
|
||||
api.dispose()
|
||||
})
|
||||
const [ready, setReady] = createSignal(false)
|
||||
TuiPluginRuntime.init(api)
|
||||
.catch((error) => {
|
||||
console.error("Failed to load TUI plugins", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
@@ -337,6 +259,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
|
||||
createEffect(() => {
|
||||
console.log(JSON.stringify(route.data))
|
||||
})
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
||||
@@ -353,13 +279,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate title to 40 chars max
|
||||
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
||||
renderer.setTerminalTitle(`OC | ${title}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.data.type === "plugin") {
|
||||
renderer.setTerminalTitle(`OC | ${route.data.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -801,7 +723,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
sdk.event.on("session.error", (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = errorMessage(error)
|
||||
const message = (() => {
|
||||
if (!error) return "An error occurred"
|
||||
|
||||
if (typeof error === "object") {
|
||||
const data = error.data
|
||||
if ("message" in data && typeof data.message === "string") {
|
||||
return data.message
|
||||
}
|
||||
}
|
||||
return String(error)
|
||||
})()
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
@@ -857,14 +789,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
exit()
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
if (!ready()) return
|
||||
if (route.data.type !== "plugin") return
|
||||
const render = routeView(route.data.id)
|
||||
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
|
||||
return render({ params: route.data.data })
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
@@ -880,22 +804,97 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}}
|
||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||
>
|
||||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<Show when={ready()}>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
{plugin()}
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
<StartupLoading ready={ready} />
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
onExit: () => Promise<void>
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
// Choose safe fallback colors per mode since theme context may not be available
|
||||
const isLight = props.mode === "light"
|
||||
const colors = {
|
||||
bg: isLight ? "#ffffff" : "#0a0a0a",
|
||||
text: isLight ? "#1a1a1a" : "#eeeeee",
|
||||
muted: isLight ? "#8a8a8a" : "#808080",
|
||||
primary: isLight ? "#3b7dd8" : "#fab283",
|
||||
}
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
||||
|
||||
const copyIssueURL = () => {
|
||||
Clipboard.copy(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
||||
Please report an issue.
|
||||
</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
||||
Copy issue URL (exception info pre-filled)
|
||||
</text>
|
||||
</box>
|
||||
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={colors.text}>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||
<text fg={colors.muted}>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text fg={colors.text}>{props.error.message}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,13 @@ import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
@@ -23,7 +21,7 @@ export type Slash = {
|
||||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: string
|
||||
keybind?: KeybindKey
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
@@ -31,7 +29,6 @@ export type CommandOption = DialogSelectOption<string> & {
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = getOwner()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
@@ -103,32 +100,11 @@ function init() {
|
||||
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const owner = getOwner() ?? root
|
||||
if (!owner) return () => {}
|
||||
|
||||
let list: Accessor<CommandOption[]> | undefined
|
||||
|
||||
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
|
||||
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
|
||||
runWithOwner(owner, () => {
|
||||
list = createMemo(cb)
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => [ref, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
})
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
|
||||
if (!list) return () => {}
|
||||
let done = false
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
}
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -16,8 +16,7 @@ export function DialogStatus() {
|
||||
|
||||
const plugins = createMemo(() => {
|
||||
const list = sync.data.config.plugin ?? []
|
||||
const result = list.map((item) => {
|
||||
const value = typeof item === "string" ? item : item[0]
|
||||
const result = list.map((value) => {
|
||||
if (value.startsWith("file://")) {
|
||||
const path = fileURLToPath(value)
|
||||
const parts = path.split("/")
|
||||
|
||||
@@ -3,22 +3,14 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
@@ -37,7 +29,12 @@ async function openWorkspace(input: {
|
||||
)
|
||||
}
|
||||
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: input.sdk.url,
|
||||
fetch: input.sdk.fetch,
|
||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
})
|
||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||
const session = listed?.data?.[0]
|
||||
if (session?.id) {
|
||||
@@ -190,7 +187,12 @@ export function DialogWorkspaceList() {
|
||||
await open(workspaceID)
|
||||
return
|
||||
}
|
||||
const client = scoped(sdk, sync, workspaceID)
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||
if (listed?.data?.length) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
@@ -221,7 +223,12 @@ export function DialogWorkspaceList() {
|
||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||
void Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const client = scoped(sdk, sync, workspace.id)
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspace.id,
|
||||
})
|
||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||
}),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
|
||||
export function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
onBeforeExit?: () => Promise<void>
|
||||
onExit: () => Promise<void>
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
await props.onBeforeExit?.()
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
// Choose safe fallback colors per mode since theme context may not be available
|
||||
const isLight = props.mode === "light"
|
||||
const colors = {
|
||||
bg: isLight ? "#ffffff" : "#0a0a0a",
|
||||
text: isLight ? "#1a1a1a" : "#eeeeee",
|
||||
muted: isLight ? "#8a8a8a" : "#808080",
|
||||
primary: isLight ? "#3b7dd8" : "#fab283",
|
||||
}
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
||||
|
||||
const copyIssueURL = () => {
|
||||
Clipboard.copy(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
||||
Please report an issue.
|
||||
</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
||||
Copy issue URL (exception info pre-filled)
|
||||
</text>
|
||||
</box>
|
||||
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={colors.text}>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||
<text fg={colors.muted}>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text fg={colors.text}>{props.error.message}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
|
||||
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.text}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const theme = useTheme().theme
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
let hold: NodeJS.Timeout | undefined
|
||||
let stamp = 0
|
||||
|
||||
createEffect(() => {
|
||||
if (props.ready()) {
|
||||
if (wait) {
|
||||
clearTimeout(wait)
|
||||
wait = undefined
|
||||
}
|
||||
if (!show()) return
|
||||
if (hold) return
|
||||
|
||||
const left = 3000 - (Date.now() - stamp)
|
||||
if (left <= 0) {
|
||||
setShow(false)
|
||||
return
|
||||
}
|
||||
|
||||
hold = setTimeout(() => {
|
||||
hold = undefined
|
||||
setShow(false)
|
||||
}, left).unref()
|
||||
return
|
||||
}
|
||||
|
||||
if (hold) {
|
||||
clearTimeout(hold)
|
||||
hold = undefined
|
||||
}
|
||||
if (show()) return
|
||||
if (wait) return
|
||||
|
||||
wait = setTimeout(() => {
|
||||
wait = undefined
|
||||
stamp = Date.now()
|
||||
setShow(true)
|
||||
}, 500).unref()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (wait) clearTimeout(wait)
|
||||
if (hold) clearTimeout(hold)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={theme.backgroundPanel} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={theme.textMuted}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { For } from "solid-js"
|
||||
import { createMemo, createSignal, For } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
@@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
|
||||
init: (input: { onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
@@ -33,7 +33,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
(reason?: unknown) => {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
await input.onBeforeExit?.()
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
|
||||
@@ -80,24 +80,21 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: string, evt: ParsedKey) {
|
||||
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||
if (!list.length) return false
|
||||
match(key: KeybindKey, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const item of list) {
|
||||
if (Keybind.match(item, parsed)) {
|
||||
for (const key of keybind) {
|
||||
if (Keybind.match(key, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
print(key: string) {
|
||||
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||
print(key: KeybindKey) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
if (!first) return ""
|
||||
const text = Keybind.toString(first)
|
||||
const lead = keybinds().leader?.[0]
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
const result = Keybind.toString(first)
|
||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export type PluginKeybindMap = Record<string, string>
|
||||
|
||||
type Base = {
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
}
|
||||
|
||||
export type PluginKeybind = {
|
||||
readonly all: PluginKeybindMap
|
||||
get: (name: string) => string
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
const txt = (value: unknown) => {
|
||||
if (typeof value !== "string") return
|
||||
if (!value.trim()) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPluginKeybind(
|
||||
base: Base,
|
||||
defaults: PluginKeybindMap,
|
||||
overrides?: Record<string, unknown>,
|
||||
): PluginKeybind {
|
||||
const all = Object.freeze(
|
||||
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||
)
|
||||
const get = (name: string) => all[name] ?? name
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return all
|
||||
},
|
||||
get,
|
||||
match: (name, evt) => base.match(get(name), evt),
|
||||
print: (name) => base.print(get(name)),
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,7 @@ export type SessionRoute = {
|
||||
initialPrompt?: PromptInfo
|
||||
}
|
||||
|
||||
export type PluginRoute = {
|
||||
type: "plugin"
|
||||
id: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
export type Route = HomeRoute | SessionRoute
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
@@ -38,6 +32,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -109,9 +109,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
get client() {
|
||||
return sdk
|
||||
},
|
||||
get workspaceID() {
|
||||
return workspaceID
|
||||
},
|
||||
directory: props.directory,
|
||||
event: emitter,
|
||||
fetch: props.fetch ?? fetch,
|
||||
|
||||
@@ -42,13 +42,66 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
import { isRecord } from "@/util/record"
|
||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
|
||||
type Theme = TuiThemeCurrent & {
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
secondary: RGBA
|
||||
accent: RGBA
|
||||
error: RGBA
|
||||
warning: RGBA
|
||||
success: RGBA
|
||||
info: RGBA
|
||||
text: RGBA
|
||||
textMuted: RGBA
|
||||
selectedListItemText: RGBA
|
||||
background: RGBA
|
||||
backgroundPanel: RGBA
|
||||
backgroundElement: RGBA
|
||||
backgroundMenu: RGBA
|
||||
border: RGBA
|
||||
borderActive: RGBA
|
||||
borderSubtle: RGBA
|
||||
diffAdded: RGBA
|
||||
diffRemoved: RGBA
|
||||
diffContext: RGBA
|
||||
diffHunkHeader: RGBA
|
||||
diffHighlightAdded: RGBA
|
||||
diffHighlightRemoved: RGBA
|
||||
diffAddedBg: RGBA
|
||||
diffRemovedBg: RGBA
|
||||
diffContextBg: RGBA
|
||||
diffLineNumber: RGBA
|
||||
diffAddedLineNumberBg: RGBA
|
||||
diffRemovedLineNumberBg: RGBA
|
||||
markdownText: RGBA
|
||||
markdownHeading: RGBA
|
||||
markdownLink: RGBA
|
||||
markdownLinkText: RGBA
|
||||
markdownCode: RGBA
|
||||
markdownBlockQuote: RGBA
|
||||
markdownEmph: RGBA
|
||||
markdownStrong: RGBA
|
||||
markdownHorizontalRule: RGBA
|
||||
markdownListItem: RGBA
|
||||
markdownListEnumeration: RGBA
|
||||
markdownImage: RGBA
|
||||
markdownImageText: RGBA
|
||||
markdownCodeBlock: RGBA
|
||||
syntaxComment: RGBA
|
||||
syntaxKeyword: RGBA
|
||||
syntaxFunction: RGBA
|
||||
syntaxVariable: RGBA
|
||||
syntaxString: RGBA
|
||||
syntaxNumber: RGBA
|
||||
syntaxType: RGBA
|
||||
syntaxOperator: RGBA
|
||||
syntaxPunctuation: RGBA
|
||||
}
|
||||
|
||||
type Theme = ThemeColors & {
|
||||
_hasSelectedListItemText: boolean
|
||||
thinkingOpacity: number
|
||||
}
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
|
||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||
// If theme explicitly defines selectedListItemText, use it
|
||||
@@ -75,10 +128,10 @@ type Variant = {
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant | RGBA
|
||||
export type ThemeJson = {
|
||||
type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
@@ -121,91 +174,27 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
carbonfox,
|
||||
}
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
const pluginThemes: Record<string, ThemeJson> = {}
|
||||
let customThemes: Record<string, ThemeJson> = {}
|
||||
let systemTheme: ThemeJson | undefined
|
||||
|
||||
function listThemes() {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes = {
|
||||
...DEFAULT_THEMES,
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
}
|
||||
if (!systemTheme) return themes
|
||||
return {
|
||||
...themes,
|
||||
system: systemTheme,
|
||||
}
|
||||
}
|
||||
|
||||
function syncThemes() {
|
||||
setStore("themes", listThemes())
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<State>({
|
||||
themes: listThemes(),
|
||||
mode: "dark",
|
||||
lock: undefined,
|
||||
active: "opencode",
|
||||
ready: false,
|
||||
})
|
||||
|
||||
export function allThemes() {
|
||||
return store.themes
|
||||
}
|
||||
|
||||
function isTheme(theme: unknown): theme is ThemeJson {
|
||||
if (!isRecord(theme)) return false
|
||||
if (!isRecord(theme.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasTheme(name: string) {
|
||||
if (!name) return false
|
||||
return allThemes()[name] !== undefined
|
||||
}
|
||||
|
||||
export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
pluginThemes[name] = theme
|
||||
syncThemes()
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||
function resolveColor(c: ColorValue): RGBA {
|
||||
if (c instanceof RGBA) return c
|
||||
if (typeof c === "string") {
|
||||
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
|
||||
if (c.startsWith("#")) return RGBA.fromHex(c)
|
||||
|
||||
if (chain.includes(c)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[c] ?? theme.theme[c as ThemeColor]
|
||||
if (next === undefined) {
|
||||
if (defs[c] != null) {
|
||||
return resolveColor(defs[c])
|
||||
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
|
||||
return resolveColor(theme.theme[c as keyof ThemeColors]!)
|
||||
} else {
|
||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(next, [...chain, c])
|
||||
}
|
||||
if (typeof c === "number") {
|
||||
return ansiToRgba(c)
|
||||
}
|
||||
return resolveColor(c[mode], chain)
|
||||
return resolveColor(c[mode])
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
@@ -214,7 +203,7 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
.map(([key, value]) => {
|
||||
return [key, resolveColor(value as ColorValue)]
|
||||
}),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
) as Partial<ThemeColors>
|
||||
|
||||
// Handle selectedListItemText separately since it's optional
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
@@ -298,18 +287,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
}
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const mode = pick(kv.get("theme_mode", props.mode))
|
||||
draft.mode = lock ?? mode ?? props.mode
|
||||
draft.lock = lock
|
||||
const active = config.theme ?? kv.get("theme", "opencode")
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
|
||||
lock,
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const theme = config.theme
|
||||
@@ -317,46 +302,52 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
function init() {
|
||||
Promise.allSettled([
|
||||
resolveSystemTheme(store.mode),
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
customThemes = custom
|
||||
syncThemes()
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
}),
|
||||
]).finally(() => {
|
||||
setStore("ready", true)
|
||||
})
|
||||
resolveSystemTheme(store.mode)
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.active !== "system") {
|
||||
setStore("ready", true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
return renderer
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors: TerminalColors) => {
|
||||
.then((colors) => {
|
||||
if (!colors.palette[0]) {
|
||||
systemTheme = undefined
|
||||
syncThemes()
|
||||
if (store.active === "system") {
|
||||
setStore("active", "opencode")
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
systemTheme = generateSystem(colors, mode)
|
||||
syncThemes()
|
||||
})
|
||||
.catch(() => {
|
||||
systemTheme = undefined
|
||||
syncThemes()
|
||||
if (store.active === "system") {
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -386,16 +377,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
apply(mode)
|
||||
}
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||
|
||||
const refresh = () => {
|
||||
renderer.clearPaletteCache()
|
||||
init()
|
||||
}
|
||||
process.on("SIGUSR2", refresh)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
process.off("SIGUSR2", refresh)
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
@@ -420,10 +403,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
return store.active
|
||||
},
|
||||
all() {
|
||||
return allThemes()
|
||||
},
|
||||
has(name: string) {
|
||||
return hasTheme(name)
|
||||
return store.themes
|
||||
},
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
@@ -443,10 +423,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
pin(mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
kv.set("theme", theme)
|
||||
return true
|
||||
},
|
||||
get ready() {
|
||||
return store.ready
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Tips } from "./tips-view"
|
||||
|
||||
const id = "internal:home-tips"
|
||||
|
||||
function View(props: { show: boolean }) {
|
||||
return (
|
||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={props.show}>
|
||||
<Tips />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
hidden: api.route.current.name !== "home",
|
||||
onSelect() {
|
||||
api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
|
||||
api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
api.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
home_bottom() {
|
||||
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
|
||||
const first = createMemo(() => api.state.session.count() === 0)
|
||||
const show = createMemo(() => !first() && !hidden())
|
||||
return <View show={show()} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-context"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
|
||||
const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0))
|
||||
|
||||
const state = createMemo(() => {
|
||||
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||
if (!last) {
|
||||
return {
|
||||
tokens: 0,
|
||||
percent: null,
|
||||
}
|
||||
}
|
||||
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||
return {
|
||||
tokens,
|
||||
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme().text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
|
||||
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
|
||||
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
sidebar_content(_ctx, props) {
|
||||
return <View api={api} session_id={props.session_id} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-files"
|
||||
|
||||
function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.session.diff(props.session_id))
|
||||
|
||||
return (
|
||||
<Show when={list().length > 0}>
|
||||
<box>
|
||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||
<Show when={list().length > 2}>
|
||||
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme().text}>
|
||||
<b>Modified Files</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={list().length <= 2 || open()}>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme().textMuted} wrapMode="none">
|
||||
{item.file}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={theme().diffAdded}>+{item.additions}</text>
|
||||
</Show>
|
||||
<Show when={item.deletions}>
|
||||
<text fg={theme().diffRemoved}>-{item.deletions}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 500,
|
||||
slots: {
|
||||
sidebar_content(_ctx, props) {
|
||||
return <View api={api} session_id={props.session_id} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Global } from "@/global"
|
||||
|
||||
const id = "internal:sidebar-footer"
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const has = createMemo(() =>
|
||||
props.api.state.provider.some(
|
||||
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
|
||||
),
|
||||
)
|
||||
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
|
||||
const show = createMemo(() => !has() && !done())
|
||||
const path = createMemo(() => {
|
||||
const dir = props.api.state.path.directory || process.cwd()
|
||||
const out = dir.replace(Global.Path.home, "~")
|
||||
const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
|
||||
const list = text.split("/")
|
||||
return {
|
||||
parent: list.slice(0, -1).join("/"),
|
||||
name: list.at(-1) ?? "",
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
<Show when={show()}>
|
||||
<box
|
||||
backgroundColor={theme().backgroundElement}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<text flexShrink={0} fg={theme().text}>
|
||||
⬖
|
||||
</text>
|
||||
<box flexGrow={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme().text}>
|
||||
<b>Getting started</b>
|
||||
</text>
|
||||
<text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
|
||||
✕
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme().textMuted}>OpenCode includes free models so you can start immediately.</text>
|
||||
<text fg={theme().textMuted}>
|
||||
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme().text}>Connect provider</text>
|
||||
<text fg={theme().textMuted}>/connect</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<text>
|
||||
<span style={{ fg: theme().textMuted }}>{path().parent}/</span>
|
||||
<span style={{ fg: theme().text }}>{path().name}</span>
|
||||
</text>
|
||||
<text fg={theme().textMuted}>
|
||||
<span style={{ fg: theme().success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme().text }}>
|
||||
<b>Code</b>
|
||||
</span>{" "}
|
||||
<span>{props.api.app.version}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
sidebar_footer() {
|
||||
return <View api={api} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-lsp"
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.lsp())
|
||||
const off = createMemo(() => props.api.state.config.lsp === false)
|
||||
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||
<Show when={list().length > 2}>
|
||||
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme().text}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={list().length <= 2 || open()}>
|
||||
<Show when={list().length === 0}>
|
||||
<text fg={theme().textMuted}>
|
||||
{off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: item.status === "connected" ? theme().success : theme().error,
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme().textMuted}>
|
||||
{item.id} {item.root}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 300,
|
||||
slots: {
|
||||
sidebar_content() {
|
||||
return <View api={api} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:sidebar-mcp"
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.mcp())
|
||||
const on = createMemo(() => list().filter((item) => item.status === "connected").length)
|
||||
const bad = createMemo(
|
||||
() =>
|
||||
list().filter(
|
||||
(item) =>
|
||||
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
|
||||
).length,
|
||||
)
|
||||
|
||||
const dot = (status: string) => {
|
||||
if (status === "connected") return theme().success
|
||||
if (status === "failed") return theme().error
|
||||
if (status === "disabled") return theme().textMuted
|
||||
if (status === "needs_auth") return theme().warning
|
||||
if (status === "needs_client_registration") return theme().error
|
||||
return theme().textMuted
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={list().length > 0}>
|
||||
<box>
|
||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||
<Show when={list().length > 2}>
|
||||
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme().text}>
|
||||
<b>MCP</b>
|
||||
<Show when={!open()}>
|
||||
<span style={{ fg: theme().textMuted }}>
|
||||
{" "}
|
||||
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={list().length <= 2 || open()}>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: dot(item.status),
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme().text} wrapMode="word">
|
||||
{item.name}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>
|
||||
<Switch fallback={item.status}>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed"}>
|
||||
<i>{item.error}</i>
|
||||
</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled</Match>
|
||||
<Match when={item.status === "needs_auth"}>Needs auth</Match>
|
||||
<Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 200,
|
||||
slots: {
|
||||
sidebar_content() {
|
||||
return <View api={api} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
|
||||
const id = "internal:sidebar-todo"
|
||||
|
||||
function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.session.todo(props.session_id))
|
||||
const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed"))
|
||||
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box>
|
||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||
<Show when={list().length > 2}>
|
||||
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme().text}>
|
||||
<b>Todo</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={list().length <= 2 || open()}>
|
||||
<For each={list()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 400,
|
||||
slots: {
|
||||
sidebar_content(_ctx, props) {
|
||||
return <View api={api} session_id={props.session_id} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { fileURLToPath } from "url"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
||||
|
||||
const id = "internal:plugin-manager"
|
||||
const key = Keybind.parse("space").at(0)
|
||||
const add = Keybind.parse("shift+i").at(0)
|
||||
const tab = Keybind.parse("tab").at(0)
|
||||
|
||||
function state(api: TuiPluginApi, item: TuiPluginStatus) {
|
||||
if (!item.enabled) {
|
||||
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
|
||||
{item.active ? "active" : "inactive"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function source(spec: string) {
|
||||
if (!spec.startsWith("file://")) return
|
||||
return fileURLToPath(spec)
|
||||
}
|
||||
|
||||
function meta(item: TuiPluginStatus, width: number) {
|
||||
if (item.source === "internal") {
|
||||
if (width >= 120) return "Built-in plugin"
|
||||
return "Built-in"
|
||||
}
|
||||
const next = source(item.spec)
|
||||
if (next) return next
|
||||
return item.spec
|
||||
}
|
||||
|
||||
function Install(props: { api: TuiPluginApi }) {
|
||||
const [global, setGlobal] = createSignal(false)
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name !== "tab") return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
if (busy()) return
|
||||
setGlobal((x) => !x)
|
||||
})
|
||||
|
||||
return (
|
||||
<props.api.ui.DialogPrompt
|
||||
title="Install plugin"
|
||||
placeholder="npm package name"
|
||||
description={() => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
||||
<text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
|
||||
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
|
||||
</box>
|
||||
)}
|
||||
onConfirm={(raw) => {
|
||||
if (busy()) return
|
||||
const mod = raw.trim()
|
||||
if (!mod) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: "Plugin package name is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
props.api.plugins
|
||||
.install(mod, { global: global() })
|
||||
.then((out) => {
|
||||
if (!out.ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: out.message,
|
||||
})
|
||||
if (out.missing) {
|
||||
props.api.ui.toast({
|
||||
variant: "info",
|
||||
message: "Check npm registry/auth settings and try again.",
|
||||
})
|
||||
}
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
props.api.ui.toast({
|
||||
variant: "success",
|
||||
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
|
||||
})
|
||||
if (!out.tui) {
|
||||
props.api.ui.toast({
|
||||
variant: "info",
|
||||
message: "Package has no TUI target to load in this app.",
|
||||
})
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
return props.api.plugins.add(mod).then((ok) => {
|
||||
if (!ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "warning",
|
||||
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
|
||||
})
|
||||
show(props.api)
|
||||
return
|
||||
}
|
||||
|
||||
props.api.ui.toast({
|
||||
variant: "success",
|
||||
message: `Loaded ${mod} in current session.`,
|
||||
})
|
||||
show(props.api)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setBusy(false)
|
||||
})
|
||||
}}
|
||||
onCancel={() => {
|
||||
show(props.api)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
|
||||
return {
|
||||
title: item.id,
|
||||
value: item.id,
|
||||
category: item.source === "internal" ? "Internal" : "External",
|
||||
description: meta(item, width),
|
||||
footer: state(api, item),
|
||||
disabled: item.id === id,
|
||||
}
|
||||
}
|
||||
|
||||
function showInstall(api: TuiPluginApi) {
|
||||
api.ui.dialog.replace(() => <Install api={api} />)
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const size = useTerminalDimensions()
|
||||
const [list, setList] = createSignal(props.api.plugins.list())
|
||||
const [cur, setCur] = createSignal<string | undefined>()
|
||||
const [lock, setLock] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const width = size().width
|
||||
if (width >= 128) {
|
||||
props.api.ui.dialog.setSize("xlarge")
|
||||
return
|
||||
}
|
||||
if (width >= 96) {
|
||||
props.api.ui.dialog.setSize("large")
|
||||
return
|
||||
}
|
||||
props.api.ui.dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const rows = createMemo(() =>
|
||||
[...list()]
|
||||
.sort((a, b) => {
|
||||
const x = a.source === "internal" ? 1 : 0
|
||||
const y = b.source === "internal" ? 1 : 0
|
||||
if (x !== y) return x - y
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
.map((item) => row(props.api, item, size().width)),
|
||||
)
|
||||
|
||||
const flip = (x: string) => {
|
||||
if (lock()) return
|
||||
const item = list().find((entry) => entry.id === x)
|
||||
if (!item) return
|
||||
setLock(true)
|
||||
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
|
||||
task
|
||||
.then((ok) => {
|
||||
if (!ok) {
|
||||
props.api.ui.toast({
|
||||
variant: "error",
|
||||
message: `Failed to update plugin ${item.id}`,
|
||||
})
|
||||
}
|
||||
setList(props.api.plugins.list())
|
||||
})
|
||||
.finally(() => {
|
||||
setLock(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Plugins"
|
||||
options={rows()}
|
||||
current={cur()}
|
||||
onMove={(item) => setCur(item.value)}
|
||||
keybind={[
|
||||
{
|
||||
title: "toggle",
|
||||
keybind: key,
|
||||
disabled: lock(),
|
||||
onTrigger: (item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "install",
|
||||
keybind: add,
|
||||
disabled: lock(),
|
||||
onTrigger: () => {
|
||||
showInstall(props.api)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onSelect={(item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function show(api: TuiPluginApi) {
|
||||
api.ui.dialog.replace(() => <View api={api} />)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: "Plugins",
|
||||
value: "plugins.list",
|
||||
keybind: "plugin_manager",
|
||||
category: "System",
|
||||
onSelect() {
|
||||
show(api)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Install plugin",
|
||||
value: "plugins.install",
|
||||
category: "System",
|
||||
onSelect() {
|
||||
showInstall(api)
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export default {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { useKeybind } from "@tui/context/keybind"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
import type { useSDK } from "@tui/context/sdk"
|
||||
import type { useSync } from "@tui/context/sync"
|
||||
import type { useTheme } from "@tui/context/theme"
|
||||
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||
import type { TuiConfig } from "@/config/tui"
|
||||
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||
import type { useKV } from "../context/kv"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
|
||||
import type { useToast } from "../ui/toast"
|
||||
import { Installation } from "@/installation"
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type RouteEntry = {
|
||||
key: symbol
|
||||
render: TuiRouteDefinition["render"]
|
||||
}
|
||||
|
||||
export type RouteMap = Map<string, RouteEntry[]>
|
||||
|
||||
type Input = {
|
||||
command: ReturnType<typeof useCommandDialog>
|
||||
tuiConfig: TuiConfig.Info
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
keybind: ReturnType<typeof useKeybind>
|
||||
kv: ReturnType<typeof useKV>
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: RouteMap
|
||||
bump: () => void
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
renderer: TuiPluginApi["renderer"]
|
||||
}
|
||||
|
||||
type TuiHostPluginApi = TuiPluginApi & {
|
||||
map: Map<string | undefined, OpencodeClient>
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
|
||||
const key = Symbol()
|
||||
for (const item of list) {
|
||||
const prev = routes.get(item.name) ?? []
|
||||
prev.push({ key, render: item.render })
|
||||
routes.set(item.name, prev)
|
||||
}
|
||||
bump()
|
||||
|
||||
return () => {
|
||||
for (const item of list) {
|
||||
const prev = routes.get(item.name)
|
||||
if (!prev) continue
|
||||
const next = prev.filter((x) => x.key !== key)
|
||||
if (!next.length) {
|
||||
routes.delete(item.name)
|
||||
continue
|
||||
}
|
||||
routes.set(item.name, next)
|
||||
}
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
|
||||
if (name === "home") {
|
||||
route.navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "session") {
|
||||
const sessionID = params?.sessionID
|
||||
if (typeof sessionID !== "string") return
|
||||
route.navigate({ type: "session", sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
route.navigate({ type: "plugin", id: name, data: params })
|
||||
}
|
||||
|
||||
function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
|
||||
if (route.data.type === "home") return { name: "home" }
|
||||
if (route.data.type === "session") {
|
||||
return {
|
||||
name: "session",
|
||||
params: {
|
||||
sessionID: route.data.sessionID,
|
||||
initialPrompt: route.data.initialPrompt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: route.data.id,
|
||||
params: route.data.data,
|
||||
}
|
||||
}
|
||||
|
||||
function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
|
||||
return {
|
||||
...item,
|
||||
onSelect: () => item.onSelect?.(),
|
||||
}
|
||||
}
|
||||
|
||||
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
|
||||
return {
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
description: item.description,
|
||||
footer: item.footer,
|
||||
category: item.category,
|
||||
disabled: item.disabled,
|
||||
}
|
||||
}
|
||||
|
||||
function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||
if (!cb) return
|
||||
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||
}
|
||||
|
||||
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return sync.ready
|
||||
},
|
||||
get config() {
|
||||
return sync.data.config
|
||||
},
|
||||
get provider() {
|
||||
return sync.data.provider
|
||||
},
|
||||
get path() {
|
||||
return sync.data.path
|
||||
},
|
||||
get vcs() {
|
||||
if (!sync.data.vcs) return
|
||||
return {
|
||||
branch: sync.data.vcs.branch,
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return sync.data.workspaceList
|
||||
},
|
||||
get(workspaceID) {
|
||||
return sync.workspace.get(workspaceID)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return sync.data.session.length
|
||||
},
|
||||
diff(sessionID) {
|
||||
return sync.data.session_diff[sessionID] ?? []
|
||||
},
|
||||
todo(sessionID) {
|
||||
return sync.data.todo[sessionID] ?? []
|
||||
},
|
||||
messages(sessionID) {
|
||||
return sync.data.message[sessionID] ?? []
|
||||
},
|
||||
status(sessionID) {
|
||||
return sync.data.session_status[sessionID]
|
||||
},
|
||||
permission(sessionID) {
|
||||
return sync.data.permission[sessionID] ?? []
|
||||
},
|
||||
question(sessionID) {
|
||||
return sync.data.question[sessionID] ?? []
|
||||
},
|
||||
},
|
||||
part(messageID) {
|
||||
return sync.data.part[messageID] ?? []
|
||||
},
|
||||
lsp() {
|
||||
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
|
||||
},
|
||||
mcp() {
|
||||
return Object.entries(sync.data.mcp)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, item]) => ({
|
||||
name,
|
||||
status: item.status,
|
||||
error: item.status === "failed" ? item.error : undefined,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function appApi(): TuiPluginApi["app"] {
|
||||
return {
|
||||
get version() {
|
||||
return Installation.VERSION
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiApi(input: Input): TuiHostPluginApi {
|
||||
const map = new Map<string | undefined, OpencodeClient>()
|
||||
const scoped: TuiPluginApi["scopedClient"] = (workspaceID) => {
|
||||
const hit = map.get(workspaceID)
|
||||
if (hit) return hit
|
||||
|
||||
const next = createOpencodeClient({
|
||||
baseUrl: input.sdk.url,
|
||||
fetch: input.sdk.fetch,
|
||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
map.set(workspaceID, next)
|
||||
return next
|
||||
}
|
||||
const workspace: TuiPluginApi["workspace"] = {
|
||||
current() {
|
||||
return input.sdk.workspaceID
|
||||
},
|
||||
set(workspaceID) {
|
||||
input.sdk.setWorkspace(workspaceID)
|
||||
},
|
||||
}
|
||||
const lifecycle: TuiPluginApi["lifecycle"] = {
|
||||
signal: new AbortController().signal,
|
||||
onDispose() {
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
app: appApi(),
|
||||
command: {
|
||||
register(cb) {
|
||||
return input.command.register(() => cb())
|
||||
},
|
||||
trigger(value) {
|
||||
input.command.trigger(value)
|
||||
},
|
||||
},
|
||||
route: {
|
||||
register(list) {
|
||||
return routeRegister(input.routes, list, input.bump)
|
||||
},
|
||||
navigate(name, params) {
|
||||
routeNavigate(input.route, name, params)
|
||||
},
|
||||
get current() {
|
||||
return routeCurrent(input.route)
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
Dialog(props) {
|
||||
return (
|
||||
<DialogUI size={props.size} onClose={props.onClose}>
|
||||
{props.children}
|
||||
</DialogUI>
|
||||
)
|
||||
},
|
||||
DialogAlert(props) {
|
||||
return <DialogAlert {...props} />
|
||||
},
|
||||
DialogConfirm(props) {
|
||||
return <DialogConfirm {...props} />
|
||||
},
|
||||
DialogPrompt(props) {
|
||||
return <DialogPrompt {...props} description={props.description} />
|
||||
},
|
||||
DialogSelect(props) {
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.title}
|
||||
placeholder={props.placeholder}
|
||||
options={props.options.map(mapOption)}
|
||||
flat={props.flat}
|
||||
onMove={mapOptionCb(props.onMove)}
|
||||
onFilter={props.onFilter}
|
||||
onSelect={mapOptionCb(props.onSelect)}
|
||||
skipFilter={props.skipFilter}
|
||||
current={props.current}
|
||||
/>
|
||||
)
|
||||
},
|
||||
toast(inputToast) {
|
||||
input.toast.show({
|
||||
title: inputToast.title,
|
||||
message: inputToast.message,
|
||||
variant: inputToast.variant ?? "info",
|
||||
duration: inputToast.duration,
|
||||
})
|
||||
},
|
||||
dialog: {
|
||||
replace(render, onClose) {
|
||||
input.dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
input.dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
input.dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return input.dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return input.dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return input.dialog.stack.length > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
match(key, evt: ParsedKey) {
|
||||
return input.keybind.match(key, evt)
|
||||
},
|
||||
print(key) {
|
||||
return input.keybind.print(key)
|
||||
},
|
||||
create(defaults, overrides) {
|
||||
return createPluginKeybind(input.keybind, defaults, overrides)
|
||||
},
|
||||
},
|
||||
get tuiConfig() {
|
||||
return input.tuiConfig
|
||||
},
|
||||
kv: {
|
||||
get(key, fallback) {
|
||||
return input.kv.get(key, fallback)
|
||||
},
|
||||
set(key, value) {
|
||||
input.kv.set(key, value)
|
||||
},
|
||||
get ready() {
|
||||
return input.kv.ready
|
||||
},
|
||||
},
|
||||
state: stateApi(input.sync),
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
scopedClient: scoped,
|
||||
workspace,
|
||||
event: input.sdk.event,
|
||||
renderer: input.renderer,
|
||||
slots: {
|
||||
register() {
|
||||
throw new Error("slots.register is only available in plugin context")
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
list() {
|
||||
return []
|
||||
},
|
||||
async activate() {
|
||||
return false
|
||||
},
|
||||
async deactivate() {
|
||||
return false
|
||||
},
|
||||
async add() {
|
||||
return false
|
||||
},
|
||||
async install() {
|
||||
return {
|
||||
ok: false,
|
||||
message: "plugins.install is only available in plugin context",
|
||||
}
|
||||
},
|
||||
},
|
||||
lifecycle,
|
||||
theme: {
|
||||
get current() {
|
||||
return input.theme.theme
|
||||
},
|
||||
get selected() {
|
||||
return input.theme.selected
|
||||
},
|
||||
has(name) {
|
||||
return input.theme.has(name)
|
||||
},
|
||||
set(name) {
|
||||
return input.theme.set(name)
|
||||
},
|
||||
async install(_jsonPath) {
|
||||
throw new Error("theme.install is only available in plugin context")
|
||||
},
|
||||
mode() {
|
||||
return input.theme.mode()
|
||||
},
|
||||
get ready() {
|
||||
return input.theme.ready
|
||||
},
|
||||
},
|
||||
map,
|
||||
dispose() {
|
||||
map.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { TuiPluginRuntime } from "./runtime"
|
||||
export { createTuiApi } from "./api"
|
||||
export type { RouteMap } from "./api"
|
||||
@@ -1,25 +0,0 @@
|
||||
import HomeTips from "../feature-plugins/home/tips"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarTodo from "../feature-plugins/sidebar/todo"
|
||||
import SidebarFiles from "../feature-plugins/sidebar/files"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import PluginManager from "../feature-plugins/system/plugins"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
|
||||
export type InternalTuiPlugin = TuiPluginModule & {
|
||||
id: string
|
||||
tui: TuiPlugin
|
||||
}
|
||||
|
||||
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
HomeTips,
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
SidebarLsp,
|
||||
SidebarTodo,
|
||||
SidebarFiles,
|
||||
SidebarFooter,
|
||||
PluginManager,
|
||||
]
|
||||
@@ -1,972 +0,0 @@
|
||||
import "@opentui/solid/runtime-plugin-support"
|
||||
import {
|
||||
type TuiDispose,
|
||||
type TuiPlugin,
|
||||
type TuiPluginApi,
|
||||
type TuiPluginInstallResult,
|
||||
type TuiPluginModule,
|
||||
type TuiPluginMeta,
|
||||
type TuiPluginStatus,
|
||||
type TuiTheme,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Log } from "@/util/log"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
getDefaultPlugin,
|
||||
isDeprecatedPlugin,
|
||||
pluginSource,
|
||||
readPluginId,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginId,
|
||||
resolvePluginTarget,
|
||||
type PluginSource,
|
||||
} from "@/plugin/shared"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
||||
import { addTheme, hasTheme } from "../context/theme"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||
import { setupSlots, Slot as View } from "./slots"
|
||||
import type { HostPluginApi, HostSlots } from "./slots"
|
||||
|
||||
type PluginLoad = {
|
||||
item?: Config.PluginSpec
|
||||
spec: string
|
||||
target: string
|
||||
retry: boolean
|
||||
source: PluginSource | "internal"
|
||||
id: string
|
||||
module: TuiPluginModule
|
||||
install_theme: TuiTheme["install"]
|
||||
}
|
||||
|
||||
type Api = HostPluginApi
|
||||
|
||||
type PluginScope = {
|
||||
lifecycle: TuiPluginApi["lifecycle"]
|
||||
track: (fn: (() => void) | undefined) => () => void
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
type PluginEntry = {
|
||||
id: string
|
||||
load: PluginLoad
|
||||
meta: TuiPluginMeta
|
||||
plugin: TuiPlugin
|
||||
options: Config.PluginOptions | undefined
|
||||
enabled: boolean
|
||||
scope?: PluginScope
|
||||
}
|
||||
|
||||
type RuntimeState = {
|
||||
directory: string
|
||||
api: Api
|
||||
slots: HostSlots
|
||||
plugins: PluginEntry[]
|
||||
plugins_by_id: Map<string, PluginEntry>
|
||||
pending: Map<
|
||||
string,
|
||||
{
|
||||
item: Config.PluginSpec
|
||||
meta: TuiConfig.PluginMeta
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "tui.plugin" })
|
||||
const DISPOSE_TIMEOUT_MS = 5000
|
||||
const KV_KEY = "plugin_enabled"
|
||||
|
||||
function fail(message: string, data: Record<string, unknown>) {
|
||||
if (!("error" in data)) {
|
||||
log.error(message, data)
|
||||
console.error(`[tui.plugin] ${message}`, data)
|
||||
return
|
||||
}
|
||||
|
||||
const text = `${message}: ${errorMessage(data.error)}`
|
||||
const next = { ...data, error: errorData(data.error) }
|
||||
log.error(text, next)
|
||||
console.error(`[tui.plugin] ${text}`, next)
|
||||
}
|
||||
|
||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||
|
||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({ type: "timeout" })
|
||||
}, ms)
|
||||
|
||||
Promise.resolve()
|
||||
.then(fn)
|
||||
.then(
|
||||
() => {
|
||||
resolve({ type: "ok" })
|
||||
},
|
||||
(error) => {
|
||||
resolve({ type: "error", error })
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isTheme(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
if (!("theme" in value)) return false
|
||||
if (!isRecord(value.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveRoot(root: string) {
|
||||
if (root.startsWith("file://")) {
|
||||
const file = fileURLToPath(root)
|
||||
if (root.endsWith("/")) return file
|
||||
return path.dirname(file)
|
||||
}
|
||||
if (path.isAbsolute(root)) return root
|
||||
return path.resolve(process.cwd(), root)
|
||||
}
|
||||
|
||||
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
||||
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
|
||||
const theme = path.basename(src, path.extname(src))
|
||||
if (hasTheme(theme)) return
|
||||
|
||||
const text = await Filesystem.readText(src).catch((error) => {
|
||||
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
||||
return
|
||||
})
|
||||
if (text === undefined) return
|
||||
|
||||
const fail = Symbol()
|
||||
const data = await Promise.resolve(text)
|
||||
.then((x) => JSON.parse(x))
|
||||
.catch((error) => {
|
||||
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
|
||||
return fail
|
||||
})
|
||||
if (data === fail) return
|
||||
|
||||
if (!isTheme(data)) {
|
||||
log.warn("invalid tui plugin theme", { path: spec, theme: src })
|
||||
return
|
||||
}
|
||||
|
||||
const source_dir = path.dirname(meta.source)
|
||||
const local_dir =
|
||||
path.basename(source_dir) === ".opencode"
|
||||
? path.join(source_dir, "themes")
|
||||
: path.join(source_dir, ".opencode", "themes")
|
||||
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
||||
const dest = path.join(dest_dir, `${theme}.json`)
|
||||
if (!(await Filesystem.exists(dest))) {
|
||||
await Filesystem.write(dest, text).catch((error) => {
|
||||
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
||||
})
|
||||
}
|
||||
|
||||
addTheme(theme, data)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExternalPlugin(
|
||||
item: Config.PluginSpec,
|
||||
meta: TuiConfig.PluginMeta | undefined,
|
||||
retry = false,
|
||||
): Promise<PluginLoad | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading tui plugin", { path: spec, retry })
|
||||
const resolved = await resolvePluginTarget(spec).catch((error) => {
|
||||
fail("failed to resolve tui plugin", { path: spec, retry, error })
|
||||
return
|
||||
})
|
||||
if (!resolved) return
|
||||
|
||||
const source = pluginSource(spec)
|
||||
if (source === "npm") {
|
||||
const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
fail("tui plugin incompatible", { path: spec, retry, error })
|
||||
return false
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const target = resolved
|
||||
if (!meta) {
|
||||
fail("missing tui plugin metadata", {
|
||||
path: spec,
|
||||
retry,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const root = resolveRoot(source === "file" ? spec : target)
|
||||
const install_theme = createThemeInstaller(meta, root, spec)
|
||||
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
|
||||
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
|
||||
return
|
||||
})
|
||||
if (!entry) return
|
||||
|
||||
const mod = await import(entry)
|
||||
.then((raw) => {
|
||||
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
|
||||
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
|
||||
return mod
|
||||
})
|
||||
.catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target, retry, error })
|
||||
return
|
||||
})
|
||||
if (!id) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
target,
|
||||
retry,
|
||||
source,
|
||||
id,
|
||||
module: mod,
|
||||
install_theme,
|
||||
}
|
||||
}
|
||||
|
||||
function createMeta(
|
||||
source: PluginLoad["source"],
|
||||
spec: string,
|
||||
target: string,
|
||||
meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
|
||||
id?: string,
|
||||
): TuiPluginMeta {
|
||||
if (meta) {
|
||||
return {
|
||||
state: meta.state,
|
||||
...meta.entry,
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
return {
|
||||
state: source === "internal" ? "same" : "first",
|
||||
id: id ?? spec,
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: target,
|
||||
}
|
||||
}
|
||||
|
||||
function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
||||
const spec = item.id
|
||||
const target = spec
|
||||
|
||||
return {
|
||||
spec,
|
||||
target,
|
||||
retry: false,
|
||||
source: "internal",
|
||||
id: item.id,
|
||||
module: item,
|
||||
install_theme: createThemeInstaller(
|
||||
{
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
process.cwd(),
|
||||
spec,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function createPluginScope(load: PluginLoad, id: string) {
|
||||
const ctrl = new AbortController()
|
||||
let list: { key: symbol; fn: TuiDispose }[] = []
|
||||
let done = false
|
||||
|
||||
const onDispose = (fn: TuiDispose) => {
|
||||
if (done) return () => {}
|
||||
const key = Symbol()
|
||||
list.push({ key, fn })
|
||||
let drop = false
|
||||
return () => {
|
||||
if (drop) return
|
||||
drop = true
|
||||
list = list.filter((x) => x.key !== key)
|
||||
}
|
||||
}
|
||||
|
||||
const track = (fn: (() => void) | undefined) => {
|
||||
if (!fn) return () => {}
|
||||
const off = onDispose(fn)
|
||||
let drop = false
|
||||
return () => {
|
||||
if (drop) return
|
||||
drop = true
|
||||
off()
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
const lifecycle: TuiPluginApi["lifecycle"] = {
|
||||
signal: ctrl.signal,
|
||||
onDispose,
|
||||
}
|
||||
|
||||
const dispose = async () => {
|
||||
if (done) return
|
||||
done = true
|
||||
ctrl.abort()
|
||||
const queue = [...list].reverse()
|
||||
list = []
|
||||
const until = Date.now() + DISPOSE_TIMEOUT_MS
|
||||
for (const item of queue) {
|
||||
const left = until - Date.now()
|
||||
if (left <= 0) {
|
||||
fail("timed out cleaning up tui plugin", {
|
||||
path: load.spec,
|
||||
id,
|
||||
timeout: DISPOSE_TIMEOUT_MS,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const out = await runCleanup(item.fn, left)
|
||||
if (out.type === "ok") continue
|
||||
if (out.type === "timeout") {
|
||||
fail("timed out cleaning up tui plugin", {
|
||||
path: load.spec,
|
||||
id,
|
||||
timeout: DISPOSE_TIMEOUT_MS,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (out.type === "error") {
|
||||
fail("failed to clean up tui plugin", {
|
||||
path: load.spec,
|
||||
id,
|
||||
error: out.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lifecycle,
|
||||
track,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function readPluginEnabledMap(value: unknown) {
|
||||
if (!isRecord(value)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((item): item is [string, boolean] => typeof item[1] === "boolean"),
|
||||
)
|
||||
}
|
||||
|
||||
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||
return {
|
||||
...readPluginEnabledMap(config.plugin_enabled),
|
||||
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
|
||||
}
|
||||
}
|
||||
|
||||
function writePluginEnabledState(api: Api, id: string, enabled: boolean) {
|
||||
api.kv.set(KV_KEY, {
|
||||
...readPluginEnabledMap(api.kv.get(KV_KEY, {})),
|
||||
[id]: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
|
||||
return state.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
source: plugin.meta.source,
|
||||
spec: plugin.meta.spec,
|
||||
target: plugin.meta.target,
|
||||
enabled: plugin.enabled,
|
||||
active: plugin.scope !== undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
||||
plugin.enabled = false
|
||||
if (persist) writePluginEnabledState(state.api, plugin.id, false)
|
||||
if (!plugin.scope) return true
|
||||
const scope = plugin.scope
|
||||
plugin.scope = undefined
|
||||
await scope.dispose()
|
||||
return true
|
||||
}
|
||||
|
||||
async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
||||
plugin.enabled = true
|
||||
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
||||
if (plugin.scope) return true
|
||||
|
||||
const scope = createPluginScope(plugin.load, plugin.id)
|
||||
const api = pluginApi(state, plugin.load, scope, plugin.id)
|
||||
const ok = await Promise.resolve()
|
||||
.then(async () => {
|
||||
await plugin.plugin(api, plugin.options, plugin.meta)
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
fail("failed to initialize tui plugin", {
|
||||
path: plugin.load.spec,
|
||||
id: plugin.id,
|
||||
error,
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!ok) {
|
||||
await scope.dispose()
|
||||
return false
|
||||
}
|
||||
|
||||
if (!plugin.enabled) {
|
||||
await scope.dispose()
|
||||
return true
|
||||
}
|
||||
|
||||
plugin.scope = scope
|
||||
return true
|
||||
}
|
||||
|
||||
async function activatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
||||
if (!state) return false
|
||||
const plugin = state.plugins_by_id.get(id)
|
||||
if (!plugin) return false
|
||||
return activatePluginEntry(state, plugin, persist)
|
||||
}
|
||||
|
||||
async function deactivatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
||||
if (!state) return false
|
||||
const plugin = state.plugins_by_id.get(id)
|
||||
if (!plugin) return false
|
||||
return deactivatePluginEntry(state, plugin, persist)
|
||||
}
|
||||
|
||||
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
|
||||
const api = runtime.api
|
||||
const host = runtime.slots
|
||||
const command: TuiPluginApi["command"] = {
|
||||
register(cb) {
|
||||
return scope.track(api.command.register(cb))
|
||||
},
|
||||
trigger(value) {
|
||||
api.command.trigger(value)
|
||||
},
|
||||
}
|
||||
|
||||
const route: TuiPluginApi["route"] = {
|
||||
register(list) {
|
||||
return scope.track(api.route.register(list))
|
||||
},
|
||||
navigate(name, params) {
|
||||
api.route.navigate(name, params)
|
||||
},
|
||||
get current() {
|
||||
return api.route.current
|
||||
},
|
||||
}
|
||||
|
||||
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
||||
install: load.install_theme,
|
||||
})
|
||||
|
||||
const event: TuiPluginApi["event"] = {
|
||||
on(type, handler) {
|
||||
return scope.track(api.event.on(type, handler))
|
||||
},
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
const slots: TuiPluginApi["slots"] = {
|
||||
register(plugin) {
|
||||
const id = count ? `${base}:${count}` : base
|
||||
count += 1
|
||||
scope.track(host.register({ ...plugin, id }))
|
||||
return id
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
app: api.app,
|
||||
command,
|
||||
route,
|
||||
ui: api.ui,
|
||||
keybind: api.keybind,
|
||||
tuiConfig: api.tuiConfig,
|
||||
kv: api.kv,
|
||||
state: api.state,
|
||||
theme,
|
||||
get client() {
|
||||
return api.client
|
||||
},
|
||||
scopedClient: api.scopedClient,
|
||||
workspace: api.workspace,
|
||||
event,
|
||||
renderer: api.renderer,
|
||||
slots,
|
||||
plugins: {
|
||||
list() {
|
||||
return listPluginStatus(runtime)
|
||||
},
|
||||
activate(id) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
},
|
||||
deactivate(id) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
},
|
||||
add(spec) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
},
|
||||
install(spec, options) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
},
|
||||
},
|
||||
lifecycle: scope.lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
|
||||
// TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
|
||||
const plugin = load.module.tui
|
||||
if (!plugin) return []
|
||||
const options = load.item ? Config.pluginOptions(load.item) : undefined
|
||||
return [
|
||||
{
|
||||
id: load.id,
|
||||
load,
|
||||
meta,
|
||||
plugin,
|
||||
options,
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
|
||||
if (state.plugins_by_id.has(plugin.id)) {
|
||||
fail("duplicate tui plugin id", {
|
||||
id: plugin.id,
|
||||
path: plugin.load.spec,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
state.plugins_by_id.set(plugin.id, plugin)
|
||||
state.plugins.push(plugin)
|
||||
return true
|
||||
}
|
||||
|
||||
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||
const map = pluginEnabledState(state, config)
|
||||
for (const plugin of state.plugins) {
|
||||
const enabled = map[plugin.id]
|
||||
if (enabled === undefined) continue
|
||||
plugin.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExternalPlugins(
|
||||
list: Config.PluginSpec[],
|
||||
wait: () => Promise<void>,
|
||||
meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
|
||||
) {
|
||||
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
|
||||
const ready: PluginLoad[] = []
|
||||
let deps: Promise<void> | undefined
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
let entry = loaded[i]
|
||||
if (!entry) {
|
||||
const item = list[i]
|
||||
if (!item) continue
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (pluginSource(spec) !== "file") continue
|
||||
deps ??= wait().catch((error) => {
|
||||
log.warn("failed waiting for tui plugin dependencies", { error })
|
||||
})
|
||||
await deps
|
||||
entry = await loadExternalPlugin(item, meta(item), true)
|
||||
}
|
||||
if (!entry) continue
|
||||
ready.push(entry)
|
||||
}
|
||||
|
||||
return ready
|
||||
}
|
||||
|
||||
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
|
||||
if (!ready.length) return { plugins: [] as PluginEntry[], ok: true }
|
||||
|
||||
const meta = await PluginMeta.touchMany(
|
||||
ready.map((item) => ({
|
||||
spec: item.spec,
|
||||
target: item.target,
|
||||
id: item.id,
|
||||
})),
|
||||
).catch((error) => {
|
||||
log.warn("failed to track tui plugins", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const plugins: PluginEntry[] = []
|
||||
let ok = true
|
||||
for (let i = 0; i < ready.length; i++) {
|
||||
const entry = ready[i]
|
||||
if (!entry) continue
|
||||
const hit = meta?.[i]
|
||||
if (hit && hit.state !== "same") {
|
||||
log.info("tui plugin metadata updated", {
|
||||
path: entry.spec,
|
||||
retry: entry.retry,
|
||||
state: hit.state,
|
||||
source: hit.entry.source,
|
||||
version: hit.entry.version,
|
||||
modified: hit.entry.modified,
|
||||
})
|
||||
}
|
||||
|
||||
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
||||
for (const plugin of collectPluginEntries(entry, row)) {
|
||||
if (!addPluginEntry(state, plugin)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
plugins.push(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
return { plugins, ok }
|
||||
}
|
||||
|
||||
function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
|
||||
return {
|
||||
scope: "local",
|
||||
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
|
||||
}
|
||||
}
|
||||
|
||||
function installCause(err: unknown) {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("cause" in err)) return
|
||||
return (err as { cause?: unknown }).cause
|
||||
}
|
||||
|
||||
function installDetail(err: unknown) {
|
||||
const hit = installCause(err) ?? err
|
||||
if (!(hit instanceof Process.RunFailedError)) {
|
||||
return {
|
||||
message: errorMessage(hit),
|
||||
missing: false,
|
||||
}
|
||||
}
|
||||
|
||||
const lines = hit.stderr
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
||||
return {
|
||||
message: errs[0] ?? lines.at(-1) ?? errorMessage(hit),
|
||||
missing: lines.some((line) => line.includes("No version matching")),
|
||||
}
|
||||
}
|
||||
|
||||
async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
||||
if (!state) return false
|
||||
const spec = raw.trim()
|
||||
if (!spec) return false
|
||||
|
||||
const pending = state.pending.get(spec)
|
||||
const item = pending?.item ?? spec
|
||||
const nextSpec = Config.pluginSpecifier(item)
|
||||
if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
|
||||
state.pending.delete(spec)
|
||||
return true
|
||||
}
|
||||
|
||||
const meta = pending?.meta ?? defaultPluginMeta(state)
|
||||
|
||||
const ready = await Instance.provide({
|
||||
directory: state.directory,
|
||||
fn: () =>
|
||||
resolveExternalPlugins(
|
||||
[item],
|
||||
() => TuiConfig.waitForDependencies(),
|
||||
() => meta,
|
||||
),
|
||||
}).catch((error) => {
|
||||
fail("failed to add tui plugin", { path: nextSpec, error })
|
||||
return [] as PluginLoad[]
|
||||
})
|
||||
if (!ready.length) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
return false
|
||||
}
|
||||
|
||||
const first = ready[0]
|
||||
if (!first) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
return false
|
||||
}
|
||||
if (state.plugins_by_id.has(first.id)) {
|
||||
state.pending.delete(spec)
|
||||
return true
|
||||
}
|
||||
|
||||
const out = await addExternalPluginEntries(state, [first])
|
||||
let ok = out.ok && out.plugins.length > 0
|
||||
for (const plugin of out.plugins) {
|
||||
const active = await activatePluginEntry(state, plugin, false)
|
||||
if (!active) ok = false
|
||||
}
|
||||
|
||||
if (ok) state.pending.delete(spec)
|
||||
if (!ok) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
async function installPluginBySpec(
|
||||
state: RuntimeState | undefined,
|
||||
raw: string,
|
||||
global = false,
|
||||
): Promise<TuiPluginInstallResult> {
|
||||
if (!state) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Plugin runtime is not ready.",
|
||||
}
|
||||
}
|
||||
|
||||
const spec = raw.trim()
|
||||
if (!spec) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Plugin package name is required",
|
||||
}
|
||||
}
|
||||
|
||||
const dir = state.api.state.path
|
||||
if (!dir.directory) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Paths are still syncing. Try again in a moment.",
|
||||
}
|
||||
}
|
||||
|
||||
const install = await installModulePlugin(spec)
|
||||
if (!install.ok) {
|
||||
const out = installDetail(install.error)
|
||||
return {
|
||||
ok: false,
|
||||
message: out.message,
|
||||
missing: out.missing,
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = await readPluginManifest(install.target)
|
||||
if (!manifest.ok) {
|
||||
if (manifest.code === "manifest_no_targets") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `"${spec}" does not declare supported targets in package.json`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: `Installed "${spec}" but failed to read ${manifest.file}`,
|
||||
}
|
||||
}
|
||||
|
||||
const patch = await patchPluginConfig({
|
||||
spec,
|
||||
targets: manifest.targets,
|
||||
global,
|
||||
vcs: dir.worktree && dir.worktree !== "/" ? "git" : undefined,
|
||||
worktree: dir.worktree,
|
||||
directory: dir.directory,
|
||||
})
|
||||
if (!patch.ok) {
|
||||
if (patch.code === "invalid_json") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Invalid JSON in ${patch.file} (${patch.parse} at line ${patch.line}, column ${patch.col})`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: errorMessage(patch.error),
|
||||
}
|
||||
}
|
||||
|
||||
const tui = manifest.targets.find((item) => item.kind === "tui")
|
||||
if (tui) {
|
||||
const file = patch.items.find((item) => item.kind === "tui")?.file
|
||||
state.pending.set(spec, {
|
||||
item: tui.opts ? [spec, tui.opts] : spec,
|
||||
meta: {
|
||||
scope: global ? "global" : "local",
|
||||
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
dir: patch.dir,
|
||||
tui: Boolean(tui),
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TuiPluginRuntime {
|
||||
let dir = ""
|
||||
let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
|
||||
export async function init(api: HostPluginApi) {
|
||||
const cwd = process.cwd()
|
||||
if (loaded) {
|
||||
if (dir !== cwd) {
|
||||
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
dir = cwd
|
||||
loaded = load(api)
|
||||
return loaded
|
||||
}
|
||||
|
||||
export function list() {
|
||||
if (!runtime) return []
|
||||
return listPluginStatus(runtime)
|
||||
}
|
||||
|
||||
export async function activatePlugin(id: string) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function deactivatePlugin(id: string) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function addPlugin(spec: string) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
}
|
||||
|
||||
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
}
|
||||
|
||||
export async function dispose() {
|
||||
const task = loaded
|
||||
loaded = undefined
|
||||
dir = ""
|
||||
if (task) await task
|
||||
const state = runtime
|
||||
runtime = undefined
|
||||
if (!state) return
|
||||
const queue = [...state.plugins].reverse()
|
||||
for (const plugin of queue) {
|
||||
await deactivatePluginEntry(state, plugin, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function load(api: Api) {
|
||||
const cwd = process.cwd()
|
||||
const slots = setupSlots(api)
|
||||
const next: RuntimeState = {
|
||||
directory: cwd,
|
||||
api,
|
||||
slots,
|
||||
plugins: [],
|
||||
plugins_by_id: new Map(),
|
||||
pending: new Map(),
|
||||
}
|
||||
runtime = next
|
||||
|
||||
await Instance.provide({
|
||||
directory: cwd,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
|
||||
}
|
||||
|
||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||
log.info("loading internal tui plugin", { id: item.id })
|
||||
const entry = loadInternalPlugin(item)
|
||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||
for (const plugin of collectPluginEntries(entry, meta)) {
|
||||
addPluginEntry(next, plugin)
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await resolveExternalPlugins(
|
||||
plugins,
|
||||
() => TuiConfig.waitForDependencies(),
|
||||
(item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
|
||||
)
|
||||
await addExternalPluginEntries(next, ready)
|
||||
|
||||
applyInitialPluginEnabledState(next, config)
|
||||
for (const plugin of next.plugins) {
|
||||
if (!plugin.enabled) continue
|
||||
// Keep plugin execution sequential for deterministic side effects:
|
||||
// command registration order affects keybind/command precedence,
|
||||
// route registration is last-wins when ids collide,
|
||||
// and hook chains rely on stable plugin ordering.
|
||||
await activatePluginEntry(next, plugin, false)
|
||||
}
|
||||
},
|
||||
}).catch((error) => {
|
||||
fail("failed to load tui plugins", { directory: cwd, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { type SlotMode, type TuiPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type SlotProps<K extends keyof TuiSlotMap> = {
|
||||
name: K
|
||||
mode?: SlotMode
|
||||
children?: JSX.Element
|
||||
} & TuiSlotMap[K]
|
||||
|
||||
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
|
||||
export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
|
||||
|
||||
export type HostPluginApi = TuiPluginApi
|
||||
export type HostSlots = {
|
||||
register: (plugin: HostSlotPlugin) => () => void
|
||||
}
|
||||
|
||||
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
|
||||
return null
|
||||
}
|
||||
|
||||
let view: Slot = empty
|
||||
|
||||
export const Slot: Slot = (props) => view(props)
|
||||
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.id !== "string") return false
|
||||
if (!isRecord(value.slots)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function setupSlots(api: HostPluginApi): HostSlots {
|
||||
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
|
||||
api.renderer,
|
||||
{
|
||||
theme: api.theme,
|
||||
},
|
||||
{
|
||||
onPluginError(event) {
|
||||
console.error("[tui.slot] plugin error", {
|
||||
plugin: event.pluginId,
|
||||
slot: event.slot,
|
||||
phase: event.phase,
|
||||
source: event.source,
|
||||
message: event.error.message,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
|
||||
view = (props) => slot(props)
|
||||
return {
|
||||
register(plugin) {
|
||||
if (!isHostSlotPlugin(plugin)) return () => {}
|
||||
return reg.register(plugin)
|
||||
},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user