Compare commits

...

88 Commits

Author SHA1 Message Date
Sebastian Herrlinger
bd48c63daa smoke test home slots 2026-03-19 12:28:00 +01:00
Sebastian Herrlinger
d53bb2f008 home slots 2026-03-19 12:28:00 +01:00
Sebastian Herrlinger
cdf8a07107 smoke test sidebar slots 2026-03-19 12:28:00 +01:00
Sebastian Herrlinger
44b38fb291 more sidebar slots 2026-03-19 12:28:00 +01:00
Sebastian Herrlinger
d290905097 fix rebase hickup 2026-03-19 12:28:00 +01:00
Sebastian Herrlinger
7538766006 no three 2026-03-19 12:28:00 +01:00
Sebastian Herrlinger
560e3cd3cf adjust to changed api 2026-03-19 12:27:20 +01:00
Sebastian Herrlinger
419448a02d working opentui version 2026-03-19 12:27:20 +01:00
Sebastian Herrlinger
d44b7edbde move runtime plugin support 2026-03-19 12:27:20 +01:00
Sebastian Herrlinger
25e11c3637 snapshot BROKEN 2026-03-19 12:27:20 +01:00
Sebastian Herrlinger
1564c421eb re-use error formatting 2026-03-19 12:27:20 +01:00
Sebastian Herrlinger
6808425e94 remove indirection 2026-03-19 12:27:20 +01:00
Sebastian Herrlinger
a7f2d499de hoist functions 2026-03-19 12:27:20 +01:00
Sebastian Herrlinger
9ecf962675 handle loading errors 2026-03-19 12:18:15 +01:00
Sebastian Herrlinger
f595d9b84e fix import 2026-03-19 12:18:15 +01:00
Sebastian Herrlinger
335bde4481 meh 2026-03-19 12:18:15 +01:00
Sebastian Herrlinger
6a33f5e1d6 upgrade opentui 2026-03-19 12:18:15 +01:00
Sebastian Herrlinger
44ffb8eb67 remove strays 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
cda5998c01 types 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
371f13ef97 fix 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
8a14dd8586 refactor 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
37e2b728a5 refactor 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
f49341aebf initial tui plugin meta pass down 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
6888e444fe fix smoke test 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
105bde7051 cleanup 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
f8b7b5cedb stash 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
4e5ce1bbc8 parallel pre-load 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
188fb92609 plugin meta 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
bf6b96a55d theme diff colors 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
567ec3d511 types 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
9acbe3ee06 keybinds 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
f247ecac77 update plugin package opentui version 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
02258af663 ttfd 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
e68a342446 new opentui snapshot 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
3106372492 fix dialog zIndex 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
86917b1c6e show when ready 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
cd8a4dcb91 ensure themes loaded 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
b05fbf8905 disambiguate 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
66a3408304 cleanup 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
421edc421f cleanup 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
fbd141a819 theme api 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
31ce7fae34 theme store 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
51de453ca0 plugin meta 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
c500605786 demo theme contrasts 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
8c73926f3a theme colors 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
5cf097d221 smoke cube 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
443ddfd3a0 btn 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
2a0b604b3b post processing 2026-03-19 12:18:14 +01:00
Sebastian Herrlinger
2ab12b62ee ascii logo for smoke test 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
22df0b4581 theming 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
74505c8085 extend demo 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
0fd1626dec refine 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
7c143341c7 stash 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
2218a41ce2 remove log file 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
81de191dfd only check outdated when online 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
1abc08a54f load file plugins immediately 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
093a2eb3d0 refine demo plugin 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
a20a1b5fa3 STASH COMPLEX PLUGIN 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
c37c5aab94 cleanup 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
85279db61f stash 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
2fe4016d39 types 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
ba2e9f5b8b cleanup sdk 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
4760b30471 cleanup sdk provider 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
3cbb0cd932 align 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
5ffff2318a dedupe 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
dda4d6aafd naming stuff 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
fb91a500c8 types 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
b662519d5f untangle 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
6917386383 tui plugin object only 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
19f1dfdf9c use opentui types 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
0c882a1720 no url or dir 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
1af4284b40 separate tui from server plugin 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
5b2bcb3639 remove custom jsx plumbing 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
550fb144b1 use build mode 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
c8c06f77cb map for ap 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
216a0392e6 versions 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
3110078af1 types regen 2026-03-19 12:18:13 +01:00
Sebastian Herrlinger
3dba48d9a0 update 2026-03-19 12:18:12 +01:00
Sebastian Herrlinger
43ac5deb42 STASH 2026-03-19 12:18:12 +01:00
Sebastian Herrlinger
c9a62bb857 ugly but works 2026-03-19 12:18:12 +01:00
Sebastian Herrlinger
d723692ecb debug 2026-03-19 12:18:12 +01:00
Sebastian Herrlinger
e4bb39b0ac smoke 2026-03-19 12:18:12 +01:00
Sebastian Herrlinger
847709acfa stash 2026-03-19 12:18:12 +01:00
Sebastian Herrlinger
042e28e591 adapt to new paste event with raw bytes 2026-03-19 12:18:12 +01:00
Sebastian Herrlinger
f159f82e52 upgrade package 2026-03-19 12:18:12 +01:00
opencode-agent[bot]
5a0bfa7061 chore: generate 2026-03-19 04:46:39 +00:00
Dax
1ac1a0287c anthropic legal requests (#18186) 2026-03-19 04:45:24 +00:00
Aiden Cline
8e09e8c612 feat: integrate multistep auth flows into desktop app (#18103) 2026-03-19 13:47:51 +10:00
66 changed files with 4724 additions and 727 deletions

View File

@@ -48,11 +48,11 @@
"light": "nord10"
},
"text": {
"dark": "nord4",
"dark": "nord6",
"light": "nord0"
},
"textMuted": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord1"
},
"background": {
@@ -64,7 +64,7 @@
"light": "nord5"
},
"backgroundElement": {
"dark": "nord1",
"dark": "nord2",
"light": "nord4"
},
"border": {
@@ -88,11 +88,11 @@
"light": "nord11"
},
"diffContext": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"diffHighlightAdded": {
@@ -104,12 +104,12 @@
"light": "nord11"
},
"diffAddedBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
"dark": "#36413C",
"light": "#E6EBE7"
},
"diffRemovedBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
"dark": "#43393D",
"light": "#ECE6E8"
},
"diffContextBg": {
"dark": "nord1",
@@ -120,12 +120,12 @@
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
"dark": "#303A35",
"light": "#DDE4DF"
},
"diffRemovedLineNumberBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
"dark": "#3C3336",
"light": "#E4DDE0"
},
"markdownText": {
"dark": "nord4",
@@ -148,7 +148,7 @@
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"markdownEmph": {
@@ -160,7 +160,7 @@
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"markdownListItem": {
@@ -184,7 +184,7 @@
"light": "nord0"
},
"syntaxComment": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"syntaxKeyword": {

View File

@@ -0,0 +1,965 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type { TuiApi, TuiKeybindSet, TuiPluginInit, TuiPluginInput, 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") return
return value as Record<string, unknown>
}
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 cash = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value && typeof value === "object") return value as RGBA
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: TuiApi) => {
return look(api.theme.current as Record<string, unknown>)
}
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: TuiApi, 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: TuiApi, 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: TuiApi, 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: TuiApi, 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: TuiApi, 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: TuiApi, 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: TuiApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginInit
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.entry.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.entry.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: TuiApi; 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 slot = (input: Cfg): TuiSlotPlugin => ({
id: "workspace-smoke",
slots: {
home_logo(ctx) {
const map = ctx.theme.current as Record<string, unknown>
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_tips(ctx, value) {
if (!value.show_tips) return null
const skin = look(ctx.theme.current as Record<string, unknown>)
return (
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> replaces the built-in home tips slot
</text>
</box>
)
},
home_below_tips(ctx, value) {
const skin = look(ctx.theme.current as Record<string, unknown>)
const text = value.first_time_user
? "first-time user state"
: value.tips_hidden
? "tips are hidden"
: "extra content below tips"
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
<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>
)
},
sidebar_top(ctx, value) {
const skin = look(ctx.theme.current as Record<string, unknown>)
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>{input.label}</b>
</text>
<text fg={skin.text}>sidebar slot active</text>
<text fg={skin.muted}>session {value.session_id.slice(0, 8)}</text>
</box>
)
},
sidebar_title(ctx, value) {
const skin = look(ctx.theme.current as Record<string, unknown>)
return (
<box paddingRight={1} flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={skin.text}>
<b>{value.title}</b>
</text>
<text fg={skin.accent}>plugin</text>
</box>
<text fg={skin.muted}>session {value.session_id.slice(0, 8)}</text>
{value.share_url ? <text fg={skin.muted}>{value.share_url}</text> : null}
</box>
)
},
sidebar_context(ctx, value) {
const skin = look(ctx.theme.current as Record<string, unknown>)
const used = value.percentage === null ? "n/a" : `${value.percentage}%`
const bar =
value.percentage === null ? "" : "■".repeat(Math.max(1, Math.min(10, Math.round(value.percentage / 10))))
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<box flexDirection="row" justifyContent="space-between">
<text fg={skin.text}>
<b>Context</b>
</text>
<text fg={skin.accent}>slot</text>
</box>
<text fg={skin.text}>{value.tokens.toLocaleString()} tokens</text>
<text fg={skin.muted}>{bar ? `${used} · ${bar}` : used}</text>
<text fg={skin.muted}>{cash.format(value.cost)} spent</text>
</box>
)
},
sidebar_files(ctx, value) {
if (!value.items.length) return null
const map = ctx.theme.current as Record<string, unknown>
const skin = look(map)
const add = ink(map, "diffAdded", "#7bd389")
const del = ink(map, "diffRemoved", "#ff8e8e")
const list = value.items.slice(0, 3)
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<box flexDirection="row" justifyContent="space-between">
<text fg={skin.text}>
<b>Working Tree</b>
</text>
<text fg={skin.accent}>{value.items.length}</text>
</box>
{list.map((item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={skin.muted} wrapMode="none">
{item.file}
</text>
<text fg={skin.text}>
{item.additions ? <span style={{ fg: add }}>+{item.additions}</span> : null}
{item.deletions ? <span style={{ fg: del }}> -{item.deletions}</span> : null}
</text>
</box>
))}
{value.items.length > list.length ? (
<text fg={skin.muted}>+{value.items.length - list.length} more file(s)</text>
) : null}
</box>
)
},
sidebar_bottom(ctx, value) {
const skin = look(ctx.theme.current as Record<string, unknown>)
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>{input.label} footer slot</b>
</text>
<text fg={skin.muted}>
append demo after {value.show_getting_started ? "welcome card" : "default footer"}
</text>
<text fg={skin.text}>
{value.directory_name} · {value.version}
</text>
</box>
)
},
},
})
const reg = (api: TuiApi, 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",
keybind: keys.get("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 (input: TuiPluginInput, options: Record<string, unknown> | null, meta: TuiPluginInit) => {
if (options?.enabled === false) return
await input.api.theme.install("./smoke-theme.json")
input.api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = input.api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
input.renderer.addPostProcessFn(fx.apply.bind(fx))
input.api.route.register([
{
name: route.screen,
render: ({ params }) => (
<Screen api={input.api} input={value} route={route} keys={keys} meta={meta} params={params} />
),
},
{
name: route.modal,
render: ({ params }) => <Modal api={input.api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(input.api, value, keys)
input.slots.register(slot(value))
}
export default {
tui,
}

1
.opencode/themes/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
smoke-theme.json

19
.opencode/tui.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": true,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
}

View File

@@ -338,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@opentui/core": "0.0.0-20260318-39211c40",
"@opentui/solid": "0.0.0-20260318-39211c40",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -425,11 +425,18 @@
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.0.0-20260318-39211c40",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.87",
},
"optionalPeers": [
"@opentui/core",
],
},
"packages/script": {
"name": "@opencode-ai/script",
@@ -1448,21 +1455,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="],
"@opentui/core": ["@opentui/core@0.0.0-20260318-39211c40", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20260318-39211c40", "@opentui/core-darwin-x64": "0.0.0-20260318-39211c40", "@opentui/core-linux-arm64": "0.0.0-20260318-39211c40", "@opentui/core-linux-x64": "0.0.0-20260318-39211c40", "@opentui/core-win32-arm64": "0.0.0-20260318-39211c40", "@opentui/core-win32-x64": "0.0.0-20260318-39211c40", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-HGMpIpTzl4vb7uwjsx9S9EoefM0saNTeYvOMMQu0ktkwuDYftWaiWgO+MubLzHuiocATMaJjLhvjaPuXUgqF/w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260318-39211c40", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XTgNp2x8ET0i2HoukUTdb/fzgdV988flCzANvuquTB5obM821peyWxqvLX1I/aq4A4PHV7Zj1v56GjZfWHSikw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260318-39211c40", "", { "os": "darwin", "cpu": "x64" }, "sha512-n7UIx8ClnkXitqpVNg386SyfSIBWVM/1CndF/zzjd+GOY0rctC2DKQxZnazHPChSXKXGQeq5xfML9Nuoi+PVkw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260318-39211c40", "", { "os": "linux", "cpu": "arm64" }, "sha512-8gOrB9xpccdNTvZNaTPWuZuIN/PnPbD6lgklbKnBqmZdHbxzfEOobJn/QAvH4KPY4ur0A4TcQkx31ZPu9i7Erg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260318-39211c40", "", { "os": "linux", "cpu": "x64" }, "sha512-eI1HUr8xmLiJ/z5JkjZgu4QBbJfIt5w7YjKugHXNiO39OexlnYa1W+Pi6ivLowTeJ1KOhLRltHUE4uCiPy/ItQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260318-39211c40", "", { "os": "win32", "cpu": "arm64" }, "sha512-JkniQxnud5q1K2jbrgLpoyKRtqIQ+TsAMmR6R0PsllJ8mjZSgggbgRpsJSagBDVvtSaYRpeFdW4fsFiEJxLsJA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260318-39211c40", "", { "os": "win32", "cpu": "x64" }, "sha512-X7GviGO1tEnu1htDh9h4ojUOtgQNqLp0bSdtWx+U8nvY3Cuj7SuffZZgWJomeDDTLyzekNZvFCy0jJi5s8UCuQ=="],
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260318-39211c40", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260318-39211c40", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-TVjGR0tCxISAQKl6g4k88YhkfE+PclPrbC+4N7mFOAUJ3i+OMsdIFg/j7bhay55P9J5tMULnO6AygiSoG0fpeg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -3820,7 +3827,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@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
@@ -5196,8 +5203,6 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5542,6 +5547,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.86", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.86", "@opentui/core-darwin-x64": "0.1.86", "@opentui/core-linux-arm64": "0.1.86", "@opentui/core-linux-x64": "0.1.86", "@opentui/core-win32-arm64": "0.1.86", "@opentui/core-win32-x64": "0.1.86", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-3tRLbI9ADrQE1jEEn4x2aJexEOQZkv9Emk2BixMZqxfVhz2zr2SxtpimDAX0vmZK3+GnWAwBWxuaCAsxZpY4+w=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.86", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.86", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-pOZC9dlZIH+bpstVVZ2AvYukBnslZTKSl/y5H8FWcMTHGv/BzpGxXBxstL65E/IQASqPFbvFcs7yMRzdLhynmA=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -5648,12 +5657,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=="],
@@ -6292,6 +6301,22 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.86", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zp7q64+d+Dcx6YrH3mRcnHq8EOBnrfc1RvjgSWLhpXr49hY6LzuhqpfZM57aGErPYlR+ff8QM6e5FUkFnDfyjw=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.86", "", { "os": "darwin", "cpu": "x64" }, "sha512-NcxfjCJm1kLnTMVOpAPdRYNi8W8XdAXNa6N7i9khiVFrl2v5KRQfUjbrSOUYVxFJNc3jKFG6rsn3jEApvn92qA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.86", "", { "os": "linux", "cpu": "arm64" }, "sha512-EDHAvqSOr8CXzbDvo1aE5blJ6wu1aSbR2LqoXtoeXHemr2T2W42D2TdIWewG6K+/BuRbzZnqt9wnYFBksLW6lw=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.86", "", { "os": "linux", "cpu": "x64" }, "sha512-VBaBkVdQDxYV4WcKjb+jgyMS5PiVHepvfaoKWpz1Bq+J01xXW4XPcXyPGkgR1+2R93KzaugEnLscTW4mWtLHlQ=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.86", "", { "os": "win32", "cpu": "arm64" }, "sha512-xKbT7sEKYKGwUPkoqmLfHjbJU+vwHPDwf/r/mIunL41JXQBB35CSZ3/QgIwpp2kkteu7oE1tdBdg15ogUU4OMg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.86", "", { "os": "win32", "cpu": "x64" }, "sha512-HRfgAUlcu71/MrtgfX4Gj7PsDtfXZiuC506Pkn1OnRN1Xomcu10BVRDweUa0/g8ldU9i9kLjMGGnpw6/NjaBFg=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -6620,6 +6645,8 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],

View File

@@ -15,7 +15,6 @@ import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider"
@@ -23,7 +22,6 @@ export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage()
const alive = { value: true }
@@ -49,13 +47,14 @@ export function DialogConnectProvider(props: { provider: string }) {
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
state: "pending" as undefined | "pending" | "complete" | "error",
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
@@ -77,6 +76,11 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
@@ -120,7 +124,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback
}
async function selectMethod(index: number) {
async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
@@ -130,6 +134,10 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await globalSDK.client.provider.oauth
@@ -137,6 +145,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{
providerID: props.provider,
method: index,
inputs,
},
{ throwOnError: true },
)
@@ -163,6 +172,122 @@ export function DialogConnectProvider(props: { provider: string }) {
}
}
function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
})
const prompts = createMemo(() => method()?.prompts ?? [])
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
return {
index,
prompt: all[index],
}
})
const valid = createMemo(() => {
const item = current()
if (!item || item.prompt.type !== "text") return false
const value = formStore.value[item.prompt.key] ?? ""
return value.trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.prompt.type !== "text") return
if (!valid()) return
await next(item.index, formStore.value)
}
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
return prompt
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.prompt.type === "text"}>
<TextField
type="text"
label={text()?.message ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const prompt = text()
if (!prompt) return
setFormStore("value", prompt.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
<Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const prompt = select()
if (!prompt) return
const nextValue = {
...formStore.value,
[prompt.key]: value.value,
}
setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue)
}}
>
{(option) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span>
</div>
)}
</List>
</div>
</div>
</Match>
</Switch>
</form>
)
}
let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -301,7 +426,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
{language.t("common.continue")}
</Button>
</form>
</div>
@@ -314,12 +439,6 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -368,7 +487,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
{language.t("common.continue")}
</Button>
</form>
</div>
@@ -386,10 +505,6 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
@@ -470,6 +585,9 @@ export function DialogConnectProvider(props: { provider: string }) {
</div>
</div>
</Match>
<Match when={store.state === "prompt"}>
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">

View File

@@ -188,7 +188,9 @@ export function StatusPopover() {
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 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"))
const overallHealthy = createMemo(() => {

View File

@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "إلغاء",
"common.connect": "اتصال",
"common.disconnect": "قطع الاتصال",
"common.continue": "إرسال",
"common.submit": "إرسال",
"common.save": "حفظ",
"common.saving": "جارٍ الحفظ...",

View File

@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Cancelar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar",
"common.save": "Salvar",
"common.saving": "Salvando...",

View File

@@ -221,6 +221,7 @@ export const dict = {
"common.cancel": "Otkaži",
"common.connect": "Poveži",
"common.disconnect": "Prekini vezu",
"common.continue": "Pošalji",
"common.submit": "Pošalji",
"common.save": "Sačuvaj",
"common.saving": "Čuvanje...",

View File

@@ -219,6 +219,7 @@ export const dict = {
"common.cancel": "Annuller",
"common.connect": "Forbind",
"common.disconnect": "Frakobl",
"common.continue": "Indsend",
"common.submit": "Indsend",
"common.save": "Gem",
"common.saving": "Gemmer...",

View File

@@ -209,6 +209,7 @@ export const dict = {
"common.cancel": "Abbrechen",
"common.connect": "Verbinden",
"common.disconnect": "Trennen",
"common.continue": "Absenden",
"common.submit": "Absenden",
"common.save": "Speichern",
"common.saving": "Speichert...",

View File

@@ -221,6 +221,7 @@ export const dict = {
"common.open": "Open",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.submit": "Submit",
"common.save": "Save",
"common.saving": "Saving...",

View File

@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Cancelar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar",
"common.save": "Guardar",
"common.saving": "Guardando...",

View File

@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Annuler",
"common.connect": "Connecter",
"common.disconnect": "Déconnecter",
"common.continue": "Soumettre",
"common.submit": "Soumettre",
"common.save": "Enregistrer",
"common.saving": "Enregistrement...",

View File

@@ -203,6 +203,7 @@ export const dict = {
"common.cancel": "キャンセル",
"common.connect": "接続",
"common.disconnect": "切断",
"common.continue": "送信",
"common.submit": "送信",
"common.save": "保存",
"common.saving": "保存中...",

View File

@@ -207,6 +207,7 @@ export const dict = {
"common.cancel": "취소",
"common.connect": "연결",
"common.disconnect": "연결 해제",
"common.continue": "제출",
"common.submit": "제출",
"common.save": "저장",
"common.saving": "저장 중...",

View File

@@ -223,6 +223,7 @@ export const dict = {
"common.cancel": "Avbryt",
"common.connect": "Koble til",
"common.disconnect": "Koble fra",
"common.continue": "Send inn",
"common.submit": "Send inn",
"common.save": "Lagre",
"common.saving": "Lagrer...",

View File

@@ -205,6 +205,7 @@ export const dict = {
"common.cancel": "Anuluj",
"common.connect": "Połącz",
"common.disconnect": "Rozłącz",
"common.continue": "Prześlij",
"common.submit": "Prześlij",
"common.save": "Zapisz",
"common.saving": "Zapisywanie...",

View File

@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Отмена",
"common.connect": "Подключить",
"common.disconnect": "Отключить",
"common.continue": "Отправить",
"common.submit": "Отправить",
"common.save": "Сохранить",
"common.saving": "Сохранение...",

View File

@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "ยกเลิก",
"common.connect": "เชื่อมต่อ",
"common.disconnect": "ยกเลิกการเชื่อมต่อ",
"common.continue": "ส่ง",
"common.submit": "ส่ง",
"common.save": "บันทึก",
"common.saving": "กำลังบันทึก...",

View File

@@ -225,6 +225,7 @@ export const dict = {
"common.cancel": "İptal",
"common.connect": "Bağlan",
"common.disconnect": "Bağlantı Kes",
"common.continue": "Gönder",
"common.submit": "Gönder",
"common.save": "Kaydet",
"common.saving": "Kaydediliyor...",

View File

@@ -242,6 +242,7 @@ export const dict = {
"common.cancel": "取消",
"common.connect": "连接",
"common.disconnect": "断开连接",
"common.continue": "提交",
"common.submit": "提交",
"common.save": "保存",
"common.saving": "保存中...",

View File

@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "取消",
"common.connect": "連線",
"common.disconnect": "中斷連線",
"common.continue": "提交",
"common.submit": "提交",
"common.save": "儲存",
"common.saving": "儲存中...",

View File

@@ -95,8 +95,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@opentui/core": "0.0.0-20260318-39211c40",
"@opentui/solid": "0.0.0-20260318-39211c40",
"@effect/platform-node": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",

View File

@@ -4,7 +4,7 @@ import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import solidPlugin from "@opentui/solid/bun-plugin"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -59,6 +59,7 @@ 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 allTargets: {
os: string
@@ -177,7 +178,7 @@ for (const item of targets) {
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
plugins: [plugin],
compile: {
autoloadBunfig: false,
autoloadDotenv: false,

View File

@@ -68,12 +68,13 @@ export namespace BunProc {
if (!modExists || !cachedVersion) {
// continue to install
} 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
if (!PackageRegistry.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) {
return mod
}
// Build command arguments

View File

@@ -9,7 +9,18 @@ export namespace PackageRegistry {
return process.execPath
}
export function online() {
const nav = globalThis.navigator
if (!nav || typeof nav.onLine !== "boolean") return true
return nav.onLine
}
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: {

View File

@@ -349,7 +349,6 @@ export const ProvidersLoginCommand = cmd({
value: x.id,
hint: {
opencode: "recommended",
anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),

View File

@@ -1,13 +1,25 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig, type ParsedKey } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import {
Switch,
Match,
createEffect,
createMemo,
untrack,
ErrorBoundary,
createSignal,
onMount,
batch,
Show,
on,
} from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { Dialog as DialogUI, DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
@@ -21,7 +33,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 } from "@tui/context/keybind"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
@@ -29,6 +41,9 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
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 { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
@@ -41,6 +56,9 @@ import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import type { TuiApi, TuiDialogSelectOption, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { TuiPlugin } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -104,6 +122,263 @@ 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: {},
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}`)
})
},
},
}
}
type RouteEntry = {
key: symbol
render: TuiRouteDefinition["render"]
}
type RouteMap = Map<string, RouteEntry[]>
type ApiInput = {
command: ReturnType<typeof useCommandDialog>
dialog: ReturnType<typeof useDialog>
keybind: ReturnType<typeof useKeybind>
route: ReturnType<typeof useRoute>
routes: RouteMap
bump: () => void
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
}
function routeRegister(routes: RouteMap, list: TuiRouteDefinition<JSX.Element>[], 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>): TuiApi<JSX.Element>["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, JSX.Element>): SelectOption<Value> {
return {
...item,
onSelect: () => item.onSelect?.(),
}
}
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value, JSX.Element> {
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, JSX.Element>) => void) {
if (!cb) return
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function createTuiApi(input: ApiInput): TuiApi<JSX.Element> {
return {
command: {
register(cb) {
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 as JSX.Element}
</DialogUI>
)
},
DialogAlert(props) {
return <DialogAlert {...props} />
},
DialogConfirm(props) {
return <DialogConfirm {...props} />
},
DialogPrompt(props) {
return <DialogPrompt {...props} description={props.description as (() => JSX.Element) | undefined} />
},
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: {
parse(evt: ParsedKey) {
return input.keybind.parse(evt)
},
match(key, evt: ParsedKey) {
return input.keybind.match(key, evt)
},
print(key) {
return input.keybind.print(key)
},
create(defaults, overrides) {
return input.keybind.create(defaults, overrides)
},
},
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
},
},
}
}
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)
}
export function tui(input: {
url: string
args: Args
@@ -129,73 +404,57 @@ export function tui(input: {
resolve()
}
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 />
</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: {},
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}`)
})
},
},
},
)
const renderer = await createCliRenderer(rendererConfig(input.config))
await 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 />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
}, renderer)
})
}
@@ -208,12 +467,43 @@ function App() {
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const sdk = useSDK()
const toast = useToast()
const { theme, mode, setMode } = useTheme()
const themeState = useTheme()
const { theme, mode, setMode } = themeState
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,
dialog,
keybind,
route,
routes,
bump: () => setRouteRev((x) => x + 1),
theme: themeState,
toast,
})
const [ready, setReady] = createSignal(false)
TuiPlugin.init({
client: sdk.client,
event: sdk.event,
renderer,
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
@@ -256,10 +546,6 @@ function App() {
}
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
@@ -276,9 +562,13 @@ function App() {
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}`)
}
})
@@ -710,17 +1000,7 @@ function App() {
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
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)
})()
const message = errorMessage(error)
toast.show({
variant: "error",
@@ -738,29 +1018,58 @@ function App() {
})
})
return (
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
const plugin = createMemo(() => {
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 })
})
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
return (
<Show when={ready()}>
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)
}
>
<Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw />
</Show>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
{plugin()}
<TuiPlugin.Slot name="app" />
</box>
</Show>
)
}
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>
)
}

View File

@@ -10,7 +10,7 @@ import {
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
@@ -21,7 +21,7 @@ export type Slash = {
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: KeybindKey
keybind?: string
suggested?: boolean
slash?: Slash
hidden?: boolean

View File

@@ -16,7 +16,8 @@ export function DialogStatus() {
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
const result = list.map((item) => {
const value = typeof item === "string" ? item : item[0]
if (value.startsWith("file://")) {
const path = fileURLToPath(value)
const parts = path.split("/")

View File

@@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
@@ -936,7 +936,7 @@ export function Prompt(props: PromptProps) {
// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
if (!pastedContent) {
command.trigger("prompt.paste")

View File

@@ -0,0 +1,44 @@
import type { ParsedKey } from "@opentui/core"
export type PluginKeybindMap = Record<string, string>
type Base<Info> = {
parse: (evt: ParsedKey) => Info
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
export type PluginKeybind<Info> = {
readonly all: PluginKeybindMap
get: (name: string) => string
parse: (evt: ParsedKey) => Info
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<Info>(
base: Base<Info>,
defaults: PluginKeybindMap,
overrides?: Record<string, unknown>,
): PluginKeybind<Info> {
const all = Object.freeze(
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
) as PluginKeybindMap
const get = (name: string) => all[name] ?? name
return {
get all() {
return all
},
get,
parse: (evt) => base.parse(evt),
match: (name, evt) => base.match(get(name), evt),
print: (name) => base.print(get(name)),
}
}

View File

@@ -6,6 +6,7 @@ import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { createPluginKeybind, type PluginKeybindMap } from "./keybind-plugin"
import { useTuiConfig } from "./tui-config"
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
@@ -80,21 +81,27 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
return Keybind.fromParsedKey(evt, store.leader)
},
match(key: KeybindKey, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
match(key: string, evt: ParsedKey) {
const list = keybinds()[key] ?? Keybind.parse(key)
if (!list.length) return false
const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
for (const item of list) {
if (Keybind.match(item, parsed)) {
return true
}
}
return false
},
print(key: KeybindKey) {
const first = keybinds()[key]?.at(0)
print(key: string) {
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
if (!first) return ""
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
const text = Keybind.toString(first)
const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
},
create(defaults: PluginKeybindMap, overrides?: Record<string, unknown>) {
return createPluginKeybind(result, defaults, overrides)
},
}
return result

View File

@@ -14,7 +14,13 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
export type Route = HomeRoute | SessionRoute
export type PluginRoute = {
type: "plugin"
id: string
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}

View File

@@ -42,6 +42,7 @@ 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"
type ThemeColors = {
primary: RGBA
@@ -174,6 +175,56 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
carbonfox,
}
type State = {
themes: Record<string, ThemeJson>
mode: "dark" | "light"
active: string
ready: boolean
}
const [store, setStore] = createStore<State>({
themes: DEFAULT_THEMES,
mode: "dark",
active: "opencode",
ready: false,
})
function mergeThemes(themes: Record<string, ThemeJson>) {
setStore(
produce((draft) => {
for (const [name, theme] of Object.entries(themes)) {
if (draft.themes[name]) continue
draft.themes[name] = theme
}
}),
)
}
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
mergeThemes({
[name]: theme,
})
return true
}
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
@@ -282,12 +333,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
init: (props: { mode: "dark" | "light" }) => {
const config = useTuiConfig()
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
setStore(
produce((draft) => {
draft.mode = kv.get("theme_mode", props.mode)
draft.active = (config.theme ?? kv.get("theme", "opencode")) as string
draft.ready = false
}),
)
createEffect(() => {
const theme = config.theme
@@ -295,55 +348,49 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
resolveSystemTheme()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
})
.finally(() => {
if (store.active !== "system") {
setStore("ready", true)
}
})
Promise.allSettled([
resolveSystemTheme(),
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
}),
]).finally(() => {
setStore("ready", true)
})
}
onMount(init)
function resolveSystemTheme() {
console.log("resolveSystemTheme")
renderer
return renderer
.getPalette({
size: 16,
})
.then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) {
if (store.active === "system") {
setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
setStore("active", "opencode")
}
return
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") {
draft.ready = true
}
}),
)
})
.catch(() => {
if (store.active === "system") {
setStore("active", "opencode")
}
})
}
const renderer = useRenderer()
@@ -370,7 +417,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.active
},
all() {
return store.themes
return allThemes()
},
has(name: string) {
return hasTheme(name)
},
syntax,
subtleSyntax,
@@ -382,8 +432,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
kv.set("theme_mode", mode)
},
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
return true
},
get ready() {
return store.ready

View File

@@ -0,0 +1,362 @@
import "@opentui/solid/runtime-plugin-support"
import {
type TuiPlugin as TuiPluginFn,
type TuiPluginInit,
type TuiPluginInput,
type TuiTheme,
type TuiSlotContext,
type TuiSlotMap,
type TuiSlots,
type SlotMode,
} from "@opencode-ai/plugin/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import type { CliRenderer } from "@opentui/core"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { TuiConfig } from "@/config/tui"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
import { PluginMeta } from "@/plugin/meta"
import { addTheme, hasTheme } from "./context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
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
type InitInput = Omit<TuiPluginInput<CliRenderer>, "slots">
type Loaded = {
item: Config.PluginSpec
spec: string
mod: Record<string, unknown>
install: TuiTheme["install"]
init: TuiPluginInit
}
type Deps = {
wait?: Promise<void>
}
const log = Log.create({ service: "tui.plugin" })
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
return null
}
function isTuiSlotPlugin(value: unknown): value is SolidPlugin<TuiSlotMap, TuiSlotContext> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
if (!isRecord(value.slots)) return false
return true
}
function getTuiSlotPlugin(value: unknown) {
if (isTuiSlotPlugin(value)) return value
if (!isRecord(value)) return
if (!isTuiSlotPlugin(value.slots)) return
return value.slots
}
function isTuiPlugin(value: unknown): value is TuiPluginFn<CliRenderer> {
return typeof value === "function"
}
function getTuiPlugin(value: unknown) {
if (!isRecord(value) || !("tui" in value)) return
if (!isTuiPlugin(value.tui)) return
return value.tui
}
function isTheme(value: unknown) {
if (!isRecord(value)) return false
if (!isRecord(value.theme)) return false
return true
}
function localDir(file: string) {
const dir = path.dirname(file)
if (path.basename(dir) === ".opencode") return path.join(dir, "themes")
return path.join(dir, ".opencode", "themes")
}
function scopeDir(pluginMeta: TuiConfig.PluginMeta) {
if (pluginMeta.scope === "local") return localDir(pluginMeta.source)
return path.join(Global.Path.config, "themes")
}
function pluginRoot(spec: string, target: string) {
if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec))
if (target.startsWith("file://")) return path.dirname(fileURLToPath(target))
return target
}
function resolveThemePath(root: string, file: string) {
if (file.startsWith("file://")) return fileURLToPath(file)
if (path.isAbsolute(file)) return file
return path.resolve(root, file)
}
function themeName(file: string) {
return path.basename(file, path.extname(file))
}
function getPluginMeta(config: TuiConfig.Info, item: Config.PluginSpec) {
const key = Config.getPluginName(item)
return config.plugin_meta?.[key]
}
function makeInstallFn(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
return async (file) => {
const src = resolveThemePath(root, file)
const theme = themeName(src)
if (hasTheme(theme)) return
const text = await Bun.file(src)
.text()
.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) as unknown)
.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 dest = path.join(scopeDir(meta), `${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)
}
}
function waitDeps(state: Deps) {
state.wait ??= TuiConfig.waitForDependencies().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
return state.wait
}
async function prepPlugin(config: TuiConfig.Info, item: Config.PluginSpec, retry = false): Promise<Loaded | undefined> {
const spec = Config.pluginSpecifier(item)
log.info("loading tui plugin", { path: spec, retry })
const target = await resolvePluginTarget(spec).catch((error) => {
log.error("failed to resolve tui plugin", { path: spec, retry, error })
return
})
if (!target) return
const meta = await PluginMeta.touch(spec, target).catch((error) => {
log.warn("failed to track tui plugin", { path: spec, retry, error })
})
if (meta && meta.state !== "same") {
log.info("tui plugin metadata updated", {
path: spec,
retry,
state: meta.state,
source: meta.entry.source,
version: meta.entry.version,
modified: meta.entry.modified,
})
}
const now = Date.now()
const init: TuiPluginInit = meta
? {
state: meta.state,
entry: meta.entry,
}
: {
state: "first",
entry: {
name: spec,
source: spec.startsWith("file://") ? "file" : "npm",
spec,
target,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: target,
},
}
const root = pluginRoot(spec, target)
const pluginMeta = getPluginMeta(config, item)
if (!pluginMeta) {
log.warn("missing tui plugin metadata", {
path: spec,
retry,
name: Config.getPluginName(item),
})
return
}
const install = makeInstallFn(pluginMeta, root, spec)
const mod = await import(target).catch((error) => {
log.error("failed to load tui plugin", { path: spec, retry, error })
return
})
if (!mod) return
return {
item,
spec,
mod,
install,
init,
}
}
async function applyPlugin(input: TuiPluginInput<CliRenderer>, load: Loaded) {
const api = {
command: input.api.command,
route: input.api.route,
ui: input.api.ui,
keybind: input.api.keybind,
theme: Object.create(input.api.theme, {
install: {
value: load.install,
configurable: true,
enumerable: true,
},
}),
} satisfies TuiPluginInput<CliRenderer>["api"]
const opts = Config.pluginOptions(load.item) ?? null
for (const [name, value] of uniqueModuleEntries(load.mod)) {
if (!value || typeof value !== "object") {
log.warn("ignoring non-object tui plugin export", {
path: load.spec,
name,
type: value === null ? "null" : typeof value,
})
continue
}
const slotPlugin = getTuiSlotPlugin(value)
if (slotPlugin) input.slots.register(slotPlugin)
const tuiPlugin = getTuiPlugin(value)
if (!tuiPlugin) continue
await tuiPlugin(
{
...input,
api,
},
opts,
load.init,
)
}
}
export namespace TuiPlugin {
let dir = ""
let loaded: Promise<void> | undefined
let view: Slot = empty
export const Slot: Slot = (props) => view(props)
function setupSlots(input: InitInput): TuiSlots {
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
input.renderer,
{
theme: input.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(pluginSlot) {
if (!isTuiSlotPlugin(pluginSlot)) return () => {}
return reg.register(pluginSlot)
},
}
}
export async function init(input: InitInput) {
const cwd = process.cwd()
if (loaded && dir === cwd) return loaded
dir = cwd
loaded = load({
...input,
slots: setupSlots(input),
})
return loaded
}
async function load(input: TuiPluginInput<CliRenderer>) {
const dir = process.cwd()
await Instance.provide({
directory: dir,
fn: async () => {
const config = await TuiConfig.get()
const plugins = config.plugin ?? []
const deps: Deps = {}
try {
const loaded = await Promise.all(plugins.map((item) => prepPlugin(config, item)))
for (let i = 0; i < plugins.length; i++) {
let entry = loaded[i]
if (!entry) {
const item = plugins[i]
if (!item) continue
const spec = Config.pluginSpecifier(item)
if (!spec.startsWith("file://")) continue
await waitDeps(deps)
entry = await prepPlugin(config, item, true)
}
if (!entry) 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 applyPlugin(input, entry)
}
} finally {
await PluginMeta.persist().catch((error) => {
log.warn("failed to persist tui plugin metadata", { error })
})
}
},
}).catch((error) => {
log.error("failed to load tui plugins", { directory: dir, error })
})
}
}

View File

@@ -15,6 +15,7 @@ import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { useLocal } from "../context/local"
import { TuiPlugin } from "../plugin"
// TODO: what is the best way to do this?
let once = false
@@ -57,8 +58,8 @@ export function Home() {
])
const Hint = (
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<box flexShrink={0} flexDirection="row" gap={1}>
<Show when={connectedMcpCount() > 0}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
@@ -71,8 +72,8 @@ export function Home() {
</Match>
</Switch>
</text>
</box>
</Show>
</Show>
</box>
)
let prompt: PromptRef
@@ -111,7 +112,9 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<Logo />
<TuiPlugin.Slot name="home_logo" mode="replace">
<Logo />
</TuiPlugin.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
@@ -124,11 +127,25 @@ export function Home() {
workspaceID={route.workspaceID}
/>
</box>
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={showTips()}>
<Tips />
</Show>
</box>
<TuiPlugin.Slot
name="home_tips"
mode="replace"
show_tips={showTips()}
tips_hidden={tipsHidden()}
first_time_user={isFirstTimeUser()}
>
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={showTips()}>
<Tips />
</Show>
</box>
</TuiPlugin.Slot>
<TuiPlugin.Slot
name="home_below_tips"
show_tips={showTips()}
tips_hidden={tipsHidden()}
first_time_user={isFirstTimeUser()}
/>
<box flexGrow={1} minHeight={0} />
<Toast />
</box>

View File

@@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"

View File

@@ -2,15 +2,17 @@ import { useSync } from "@tui/context/sync"
import { createMemo, For, Show, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { Global } from "@/global"
import { Installation } from "@/installation"
import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
import { TuiPlugin } from "../../plugin"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
@@ -27,36 +29,40 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
lsp: true,
})
// Sort MCP servers alphabetically for consistent display order
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
const mcp = createMemo(() =>
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,
})),
)
// Count connected and error MCP servers for collapsed header display
const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
const lsp = createMemo(() => sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status })))
const connectedMcpCount = createMemo(() => mcp().filter((item) => item.status === "connected").length)
const errorMcpCount = createMemo(
() =>
mcpEntries().filter(
([_, item]) =>
mcp().filter(
(item) =>
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
).length,
)
const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const cost = createMemo(() => messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0))
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
if (!last) return
const total =
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as
| AssistantMessage
| undefined
if (!last) return { tokens: 0, percentage: null as number | null }
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
tokens,
percentage: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
}
})
@@ -67,6 +73,16 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
const showGettingStarted = createMemo(() => !hasProviders() && !gettingStartedDismissed())
const dir = createMemo(() => {
const value = directory()
const parts = value.split("/")
return {
value,
parent: parts.slice(0, -1).join("/"),
name: parts.at(-1) ?? "",
}
})
return (
<Show when={session()}>
@@ -90,163 +106,200 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
<TuiPlugin.Slot name="sidebar_top" session_id={props.sessionID} />
<TuiPlugin.Slot
name="sidebar_title"
mode="replace"
session_id={props.sessionID}
title={session().title}
share_url={session().share?.url}
>
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
</Show>
</box>
</TuiPlugin.Slot>
<TuiPlugin.Slot
name="sidebar_context"
mode="replace"
session_id={props.sessionID}
tokens={context().tokens}
percentage={context().percentage}
cost={cost()}
>
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context().tokens.toLocaleString()} tokens</text>
<text fg={theme.textMuted}>{context().percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{money.format(cost())} spent</text>
</box>
</TuiPlugin.Slot>
<TuiPlugin.Slot
name="sidebar_mcp"
mode="replace"
session_id={props.sessionID}
items={mcp()}
connected={connectedMcpCount()}
errors={errorMcpCount()}
>
<Show when={mcp().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => mcp().length > 2 && setExpanded("mcp", !expanded.mcp)}
>
<Show when={mcp().length > 2}>
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
<Show when={!expanded.mcp}>
<span style={{ fg: theme.textMuted }}>
{" "}
({connectedMcpCount()} active
{errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
</span>
</Show>
</text>
</box>
<Show when={mcp().length <= 2 || expanded.mcp}>
<For each={mcp()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: (
{
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>
)[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>
</box>
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={mcpEntries().length > 0}>
</TuiPlugin.Slot>
<TuiPlugin.Slot
name="sidebar_lsp"
mode="replace"
session_id={props.sessionID}
items={lsp()}
disabled={sync.data.config.lsp === false}
>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
onMouseDown={() => lsp().length > 2 && setExpanded("lsp", !expanded.lsp)}
>
<Show when={mcpEntries().length > 2}>
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
<Show when={lsp().length > 2}>
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
<Show when={!expanded.mcp}>
<span style={{ fg: theme.textMuted }}>
{" "}
({connectedMcpCount()} active
{errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
</span>
</Show>
<b>LSP</b>
</text>
</box>
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
<For each={mcpEntries()}>
{([key, item]) => (
<Show when={lsp().length <= 2 || expanded.lsp}>
<Show when={lsp().length === 0}>
<text fg={theme.textMuted}>
{sync.data.config.lsp === false
? "LSPs have been disabled in settings"
: "LSPs will activate as files are read"}
</text>
</Show>
<For each={lsp()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: (
{
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>
)[item.status],
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "disabled"}>Disabled</Match>
<Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
<Match when={(item.status as string) === "needs_client_registration"}>
Needs client ID
</Match>
</Switch>
</span>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</Show>
</box>
</Show>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
>
<Show when={sync.data.lsp.length > 2}>
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
</box>
<Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
<Show when={sync.data.lsp.length === 0}>
<text fg={theme.textMuted}>
{sync.data.config.lsp === false
? "LSPs have been disabled in settings"
: "LSPs will activate as files are read"}
</text>
</Show>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</TuiPlugin.Slot>
<TuiPlugin.Slot name="sidebar_todo" mode="replace" session_id={props.sessionID} items={todo()}>
<Show when={todo().length > 0 && todo().some((item) => item.status !== "completed")}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
>
<Show when={todo().length > 2}>
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={todo().length <= 2 || expanded.todo}>
<For each={todo()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
</Show>
</box>
</Show>
</box>
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
>
<Show when={todo().length > 2}>
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={todo().length <= 2 || expanded.todo}>
<For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
</Show>
</box>
</Show>
<Show when={diff().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
>
<Show when={diff().length > 2}>
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={diff().length <= 2 || expanded.diff}>
<For each={diff() || []}>
{(item) => {
return (
</TuiPlugin.Slot>
<TuiPlugin.Slot name="sidebar_files" mode="replace" session_id={props.sessionID} items={diff()}>
<Show when={diff().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
>
<Show when={diff().length > 2}>
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={diff().length <= 2 || expanded.diff}>
<For each={diff()}>
{(item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} wrapMode="none">
{item.file}
@@ -260,60 +313,96 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
</box>
</box>
)
}}
</For>
</Show>
</box>
</Show>
)}
</For>
</Show>
</box>
</Show>
</TuiPlugin.Slot>
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<Show when={!hasProviders() && !gettingStartedDismissed()}>
<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={() => 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
<TuiPlugin.Slot
name="sidebar_getting_started"
mode="replace"
session_id={props.sessionID}
show_getting_started={showGettingStarted()}
has_providers={hasProviders()}
dismissed={gettingStartedDismissed()}
>
<Show when={showGettingStarted()}>
<box
backgroundColor={theme.backgroundElement}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
gap={1}
>
<text flexShrink={0} fg={theme.text}>
</text>
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.text}>Connect provider</text>
<text fg={theme.textMuted}>/connect</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={() => 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>
</box>
</Show>
<text>
<span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
<span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</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>{Installation.VERSION}</span>
</text>
</Show>
</TuiPlugin.Slot>
<TuiPlugin.Slot
name="sidebar_directory"
mode="replace"
session_id={props.sessionID}
directory={dir().value}
directory_parent={dir().parent}
directory_name={dir().name}
>
<text>
<span style={{ fg: theme.textMuted }}>{dir().parent}/</span>
<span style={{ fg: theme.text }}>{dir().name}</span>
</text>
</TuiPlugin.Slot>
<TuiPlugin.Slot
name="sidebar_version"
mode="replace"
session_id={props.sessionID}
version={Installation.VERSION}
>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>
<b>Code</b>
</span>{" "}
<span>{Installation.VERSION}</span>
</text>
</TuiPlugin.Slot>
<TuiPlugin.Slot
name="sidebar_bottom"
session_id={props.sessionID}
directory={dir().value}
directory_parent={dir().parent}
directory_name={dir().name}
version={Installation.VERSION}
show_getting_started={showGettingStarted()}
has_providers={hasProviders()}
dismissed={gettingStartedDismissed()}
/>
</box>
</box>
</Show>

View File

@@ -35,6 +35,7 @@ export function Dialog(
height={dimensions().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4}
left={0}
top={0}
@@ -72,6 +73,9 @@ function init() {
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -151,6 +155,7 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
zIndex={3000}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return

View File

@@ -1,6 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
@@ -34,6 +34,7 @@ import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
@@ -41,6 +42,11 @@ import { Lock } from "@/util/lock"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const PluginOptions = z.record(z.string(), z.unknown())
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec>
const log = Log.create({ service: "config" })
@@ -355,8 +361,9 @@ export namespace Config {
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
if (!PackageRegistry.online()) return false
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!stale) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
@@ -495,7 +502,7 @@ export namespace Config {
}
async function loadPlugin(dir: string) {
const plugins: string[] = []
const plugins: PluginSpec[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
@@ -508,6 +515,32 @@ export namespace Config {
return plugins
}
export function pluginSpecifier(plugin: PluginSpec): string {
return Array.isArray(plugin) ? plugin[0] : plugin
}
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
return Array.isArray(plugin) ? plugin[1] : undefined
}
export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec {
const spec = pluginSpecifier(plugin)
try {
const resolved = import.meta.resolve!(spec, configFilepath)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
try {
const require = createRequire(configFilepath)
const resolved = pathToFileURL(require.resolve(spec)).href
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
return plugin
}
}
}
/**
* Extracts a canonical plugin name from a plugin specifier.
* - For file:// URLs: extracts filename without extension
@@ -518,15 +551,16 @@ export namespace Config {
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
*/
export function getPluginName(plugin: string): string {
if (plugin.startsWith("file://")) {
return path.parse(new URL(plugin).pathname).name
export function getPluginName(plugin: PluginSpec): string {
const spec = pluginSpecifier(plugin)
if (spec.startsWith("file://")) {
return path.parse(new URL(spec).pathname).name
}
const lastAt = plugin.lastIndexOf("@")
const lastAt = spec.lastIndexOf("@")
if (lastAt > 0) {
return plugin.substring(0, lastAt)
return spec.substring(0, lastAt)
}
return plugin
return spec
}
/**
@@ -540,14 +574,14 @@ export namespace Config {
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
export function deduplicatePlugins(plugins: string[]): string[] {
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
// seenNames: canonical plugin names for duplicate detection
// e.g., "oh-my-opencode", "@scope/pkg"
const seenNames = new Set<string>()
// uniqueSpecifiers: full plugin specifiers to return
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
const uniqueSpecifiers: string[] = []
// e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }]
const uniqueSpecifiers: PluginSpec[] = []
for (const specifier of plugins.toReversed()) {
const name = getPluginName(specifier)
@@ -1051,13 +1085,13 @@ export namespace Config {
ignore: z.array(z.string()).optional(),
})
.optional(),
plugin: z.string().array().optional(),
snapshot: z
.boolean()
.optional()
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
plugin: PluginSpec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -1304,19 +1338,7 @@ export namespace Config {
const data = parsed.data
if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, options.path)
} catch (e) {
try {
// import.meta.resolve sometimes fails with newly created node_modules
const require = createRequire(options.path)
const resolvedPath = require.resolve(plugin)
data.plugin[i] = pathToFileURL(resolvedPath).href
} catch {
// Ignore, plugin might be a generic string identifier like "mcp-server"
}
}
data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
}
}
return data
@@ -1363,10 +1385,6 @@ export namespace Config {
return candidates[0]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {
@@ -1460,5 +1478,3 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write

View File

@@ -29,6 +29,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(),
})
.extend(TuiOptions.shape)
.strict()

View File

@@ -8,6 +8,7 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
export namespace TuiConfig {
@@ -15,16 +16,94 @@ export namespace TuiConfig {
export const Info = TuiInfo
export type Info = z.output<typeof Info>
export type PluginMeta = {
scope: "global" | "local"
source: string
}
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
}
type Acc = {
result: Info
entries: PluginEntry[]
}
export type Info = z.output<typeof Info> & {
plugin_meta?: Record<string, PluginMeta>
}
function pluginScope(file: string): PluginMeta["scope"] {
if (Instance.containsPath(file)) return "local"
return "global"
}
function dedupePlugins(list: PluginEntry[]) {
const seen = new Set<string>()
const result: PluginEntry[] = []
for (const item of list.toReversed()) {
const name = Config.getPluginName(item.item)
if (seen.has(name)) continue
seen.add(name)
result.push(item)
}
return result.toReversed()
}
function mergeInfo(target: Info, source: Info): Info {
return mergeDeep(target, source)
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = [...target.plugin, ...source.plugin]
}
return merged
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
function installDeps(dir: string): Promise<void> {
return Config.needsInstall(dir).then((yes) => {
if (!yes) return
return Config.installDependencies(dir)
})
}
async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file)
acc.result = mergeInfo(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
for (const item of data.plugin) {
acc.entries.push({
item,
meta: {
scope,
source: file,
},
})
}
}
const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
@@ -38,38 +117,55 @@ export namespace TuiConfig {
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
const acc: Acc = {
result: {},
entries: [],
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
if (custom) {
result = mergeInfo(result, await loadFile(custom))
await mergeFile(acc, custom)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
}
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
acc.result.plugin = merged.map((item) => item.item)
acc.result.plugin_meta = merged.length
? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta]))
: undefined
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(installDeps(dir))
}
}
return {
config: result,
config: acc.result,
deps,
}
})
@@ -77,6 +173,11 @@ export namespace TuiConfig {
return state().then((x) => x.config)
}
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@@ -87,25 +188,12 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => {
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
...copy,
}
})()
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
@@ -113,6 +201,13 @@ export namespace TuiConfig {
return {}
}
return parsed.data
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
}
}

View File

@@ -16,10 +16,12 @@ export namespace Flag {
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
@@ -114,6 +116,17 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
configurable: false,
})
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
get() {
return process.env["OPENCODE_PLUGIN_META_FILE"]
},
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime

View File

@@ -4,23 +4,89 @@ import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-anthropic-auth@0.0.13"]
type Loaded = {
item: Config.PluginSpec
spec: string
mod: Record<string, unknown>
}
const BUILTIN = ["opencode-anthropic-auth@0.0.13"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
}
function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin(value.server)) return
return value.server
}
async function resolvePlugin(spec: string) {
const parsed = parsePluginSpecifier(spec)
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!target) return
return target
}
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
const spec = Config.pluginSpecifier(item)
// ignore old codex plugin since it is supported first party now
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) return
log.info("loading plugin", { path: spec })
const target = await resolvePlugin(spec)
if (!target) return
const mod = await import(target).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
})
return
})
if (!mod) return
return {
item,
spec,
mod,
}
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
for (const [, entry] of uniqueModuleEntries(load.mod)) {
const server = getServerPlugin(entry)
if (!server) throw new TypeError("Plugin export is not a function")
hooks.push(await server(input, Config.pluginOptions(load.item)))
}
}
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
@@ -59,48 +125,24 @@ export namespace Plugin {
plugins = [...BUILTIN, ...plugins]
}
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!plugin) continue
}
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
for (const load of loaded) {
if (!load) continue
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
// uniqueModuleEntries keeps only the first export for each shared value reference.
await applyPlugin(load, input, hooks).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: load.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
})
})
}
return {

View File

@@ -0,0 +1,160 @@
import path from "path"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { parsePluginSpecifier } from "./shared"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Entry = {
name: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
}
export type State = "first" | "updated" | "same"
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
const cache = {
ready: false,
path: "",
store: {} as Store,
dirty: false,
}
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function sourceKind(spec: string): Source {
if (spec.startsWith("file://")) return "file"
return "npm"
}
function entryKey(spec: string) {
if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}`
return `npm:${parsePluginSpecifier(spec).pkg}`
}
function entryName(spec: string) {
if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name
return parsePluginSpecifier(spec).pkg
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
function modifiedAt(file: string) {
const stat = Filesystem.stat(file)
if (!stat) return
const value = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value)
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function entryCore(spec: string, target: string): Promise<Core> {
const source = sourceKind(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
name: entryName(spec),
source,
spec,
target,
modified: file ? modifiedAt(file) : undefined,
}
}
return {
name: entryName(spec),
source,
spec,
target,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
}
}
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
async function load() {
const next = storePath()
if (cache.ready && cache.path === next) return
cache.path = next
cache.store = await Filesystem.readJson<Store>(next).catch(() => ({}) as Store)
cache.dirty = false
cache.ready = true
}
export async function touch(spec: string, target: string): Promise<{ state: State; entry: Entry }> {
await load()
const now = Date.now()
const id = entryKey(spec)
const prev = cache.store[id]
const core = await entryCore(spec, target)
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
cache.store[id] = entry
cache.dirty = true
return {
state,
entry,
}
}
export async function persist() {
await load()
if (!cache.dirty) return
await Filesystem.writeJson(cache.path, cache.store)
cache.dirty = false
}
export async function list(): Promise<Store> {
await load()
return { ...cache.store }
}
}

View File

@@ -0,0 +1,26 @@
import { BunProc } from "@/bun"
export function parsePluginSpecifier(spec: string) {
const lastAt = spec.lastIndexOf("@")
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
return { pkg, version }
}
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (spec.startsWith("file://")) return spec
return BunProc.install(parsed.pkg, parsed.version)
}
export function uniqueModuleEntries(mod: Record<string, unknown>) {
const seen = new Set<unknown>()
const entries: [string, unknown][] = []
for (const [name, entry] of Object.entries(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
entries.push([name, entry])
}
return entries
}

View File

@@ -150,8 +150,7 @@ export namespace Provider {
autoload: false,
options: {
headers: {
"anthropic-beta":
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
},
},
}

View File

@@ -207,18 +207,12 @@ export namespace LLM {
maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: input.model.providerID !== "anthropic"
? {
"User-Agent": `opencode/${Installation.VERSION}`,
}
: undefined),
...(input.model.providerID.startsWith("opencode") && {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}),
...input.model.headers,
...headers,
},

View File

@@ -1,166 +0,0 @@
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using Claude Code
- To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues
When the user directly asks about Claude Code (eg. "can Claude Code do...", "does Claude Code have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific Claude Code feature (eg. implement a hook, or write a slash command), use the WebFetch tool to gather information to answer the question from Claude Code docs. The list of available docs is available at https://docs.claude.com/en/docs/claude-code/claude_code_docs_map.md.
# Tone and style
You should be concise, direct, and to the point, while providing complete information and matching the level of detail you provide in your response with the level of complexity of the user's query or the work you have completed.
A concise response is generally less than 4 lines, not including tool calls or code generated. You should provide more detail when the task is complex or when the user asks you to.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
Do not add additional code explanation summary unless requested by the user. After working on a file, briefly confirm that you have completed the task, rather than providing an explanation of what you did.
Answer the user's question directly, avoiding any elaboration, explanation, introduction, conclusion, or excessive details. Brief answers are best, but be sure to provide complete information. You MUST avoid extra preamble before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [runs ls to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface.
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
- Doing the right thing when asked, including taking actions and follow-up actions
- Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
# Professional objectivity
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
# Task Management
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using Bash.
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including <user-prompt-submit-hook>, as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- Use the TodoWrite tool to plan the task if required
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
Here is useful information about the environment you are running in:
<env>
Working directory: /home/thdxr/dev/projects/anomalyco/opencode/packages/opencode
Is directory a git repo: Yes
Platform: linux
OS Version: Linux 6.12.4-arch1-1
Today's date: 2025-09-30
</env>
You are powered by the model named Sonnet 4.5. The exact model ID is claude-sonnet-4-5-20250929.
Assistant knowledge cutoff is January 2025.
IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>

View File

@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test"
import type { ParsedKey } from "@opentui/core"
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
describe("createPluginKeybind", () => {
const defaults = {
open: "ctrl+o",
close: "escape",
}
test("uses defaults when overrides are missing", () => {
const api = {
parse: () => "parsed",
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults)
expect(bind.all).toEqual(defaults)
expect(bind.get("open")).toBe("ctrl+o")
expect(bind.get("close")).toBe("escape")
})
test("applies valid overrides", () => {
const api = {
parse: () => "parsed",
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+alt+o",
close: "q",
})
expect(bind.all).toEqual({
open: "ctrl+alt+o",
close: "q",
})
})
test("ignores invalid overrides", () => {
const api = {
parse: () => "parsed",
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: " ",
close: 1,
extra: "ctrl+x",
})
expect(bind.all).toEqual(defaults)
expect(bind.get("extra")).toBe("extra")
})
test("delegates parse", () => {
const evt = { name: "x" } as ParsedKey
const api = {
parse: (value: ParsedKey) => value,
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults)
expect(bind.parse(evt)).toBe(evt)
})
test("resolves names for match", () => {
const list: string[] = []
const api = {
parse: () => "parsed",
match: (key: string) => {
list.push(key)
return true
},
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+shift+o",
})
bind.match("open", { name: "x" } as ParsedKey)
bind.match("ctrl+k", { name: "x" } as ParsedKey)
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
})
test("resolves names for print", () => {
const list: string[] = []
const api = {
parse: () => "parsed",
match: () => false,
print: (key: string) => {
list.push(key)
return `print:${key}`
},
}
const bind = createPluginKeybind(api, defaults, {
close: "q",
})
expect(bind.print("close")).toBe("print:q")
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
expect(list).toEqual(["q", "ctrl+p"])
})
})

View File

@@ -0,0 +1,216 @@
import { expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { CliRenderer } from "@opentui/core"
import { tmpdir } from "../../fixture/fixture"
import { Log } from "../../../src/util/log"
import { TuiConfig } from "../../../src/config/tui"
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
mock.module("@opentui/solid/preload", () => ({}))
mock.module("@opentui/solid", () => ({
createSolidSlotRegistry: () => ({
register: () => () => {},
}),
createSlot: () => () => null,
useRenderer: () => ({
getPalette: async () => ({ palette: [] as string[] }),
clearPaletteCache: () => {},
}),
}))
mock.module("@opentui/solid/jsx-runtime", () => ({
Fragment: Symbol.for("Fragment"),
jsx: () => null,
jsxs: () => null,
jsxDEV: () => null,
}))
const { TuiPlugin } = await import("../../../src/cli/cmd/tui/plugin")
async function waitForLog(text: string, timeout = 1000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const file = Log.file()
if (file) {
const content = await Bun.file(file)
.text()
.catch(() => "")
if (content.includes(text)) return content
}
await Bun.sleep(25)
}
return Bun.file(Log.file())
.text()
.catch(() => "")
}
test("continues loading tui plugins when a plugin is missing config metadata", async () => {
const stamp = Date.now()
await using tmp = await tmpdir({
init: async (dir) => {
const badPluginPath = path.join(dir, `missing-meta-plugin-${stamp}.ts`)
const nextPluginPath = path.join(dir, `next-plugin-${stamp}.ts`)
const badSpec = pathToFileURL(badPluginPath).href
const nextSpec = pathToFileURL(nextPluginPath).href
const badMarker = path.join(dir, "missing-meta-called.txt")
const nextMarker = path.join(dir, "next-called.txt")
await Bun.write(
badPluginPath,
`export default {
tui: async (_input, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
await Bun.write(
nextPluginPath,
`export default {
tui: async (_input, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
return {
badSpec,
nextSpec,
badMarker,
nextMarker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const bad = path.parse(new URL(tmp.extra.badSpec).pathname).name
const next = path.parse(new URL(tmp.extra.nextSpec).pathname).name
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [
[tmp.extra.badSpec, { marker: tmp.extra.badMarker }],
[tmp.extra.nextSpec, { marker: tmp.extra.nextMarker }],
],
plugin_meta: {
[next]: {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
},
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
let selected = "opencode"
const renderer = {
...Object.create(null),
once(this: CliRenderer) {
return this
},
} satisfies CliRenderer
const keybind = {
parse: (evt: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) => ({
name: evt.name ?? "",
ctrl: evt.ctrl ?? false,
meta: evt.meta ?? false,
shift: evt.shift ?? false,
super: evt.super,
leader: false,
}),
match: () => false,
print: (key: string) => key,
}
try {
await TuiPlugin.init({
client: createOpencodeClient({
baseUrl: "http://localhost:4096",
}),
event: {
on: () => () => {},
},
renderer,
api: {
command: {
register: () => {},
trigger: () => {},
},
route: {
register: () => () => {},
navigate: () => {},
get current() {
return { name: "home" as const }
},
},
ui: {
Dialog: () => null,
DialogAlert: () => null,
DialogConfirm: () => null,
DialogPrompt: () => null,
DialogSelect: () => null,
toast: () => {},
dialog: {
replace: () => {},
clear: () => {},
setSize: () => {},
get size() {
return "medium" as const
},
get depth() {
return 0
},
get open() {
return false
},
},
},
keybind: {
...keybind,
create(defaults, overrides) {
return createPluginKeybind(keybind, defaults, overrides)
},
},
theme: {
get current() {
return {}
},
get selected() {
return selected
},
has() {
return false
},
set(name) {
selected = name
return true
},
async install() {
throw new Error("base theme.install should not run")
},
mode() {
return "dark" as const
},
get ready() {
return true
},
},
},
})
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).rejects.toThrow()
await expect(fs.readFile(tmp.extra.nextMarker, "utf8")).resolves.toBe("called")
const log = await waitForLog("missing tui plugin metadata")
expect(log).toContain("missing tui plugin metadata")
expect(log).toContain(`name=${bad}`)
} finally {
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -0,0 +1,536 @@
import { expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { CliRenderer } from "@opentui/core"
import { tmpdir } from "../../fixture/fixture"
import { Log } from "../../../src/util/log"
import { Global } from "../../../src/global"
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
mock.module("@opentui/solid/preload", () => ({}))
mock.module("@opentui/solid", () => ({
createSolidSlotRegistry: () => ({
register: () => () => {},
}),
createSlot: () => () => null,
useRenderer: () => ({
getPalette: async () => ({ palette: [] as string[] }),
clearPaletteCache: () => {},
}),
}))
mock.module("@opentui/solid/jsx-runtime", () => ({
Fragment: Symbol.for("Fragment"),
jsx: () => null,
jsxs: () => null,
jsxDEV: () => null,
}))
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
const { TuiPlugin } = await import("../../../src/cli/cmd/tui/plugin")
const { PluginMeta } = await import("../../../src/plugin/meta")
async function waitForLog(text: string, timeout = 1000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const file = Log.file()
if (file) {
const content = await Bun.file(file)
.text()
.catch(() => "")
if (content.includes(text)) return content
}
await Bun.sleep(25)
}
return Bun.file(Log.file())
.text()
.catch(() => "")
}
test("loads plugin theme and keybind APIs with scoped theme installation", async () => {
const stamp = Date.now()
const globalConfigPath = path.join(Global.Path.config, "tui.json")
const backup = await Bun.file(globalConfigPath)
.text()
.catch(() => undefined)
await using tmp = await tmpdir({
init: async (dir) => {
const localPluginPath = path.join(dir, "local-plugin.ts")
const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
const globalPluginPath = path.join(dir, "global-plugin.ts")
const localSpec = pathToFileURL(localPluginPath).href
const invalidSpec = pathToFileURL(invalidPluginPath).href
const preloadedSpec = pathToFileURL(preloadedPluginPath).href
const globalSpec = pathToFileURL(globalPluginPath).href
const localThemeFile = `local-theme-${stamp}.json`
const invalidThemeFile = `invalid-theme-${stamp}.json`
const globalThemeFile = `global-theme-${stamp}.json`
const preloadedThemeFile = `preloaded-theme-${stamp}.json`
const localThemeName = localThemeFile.replace(/\.json$/, "")
const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
const globalThemeName = globalThemeFile.replace(/\.json$/, "")
const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
const localThemePath = path.join(dir, localThemeFile)
const invalidThemePath = path.join(dir, invalidThemeFile)
const globalThemePath = path.join(dir, globalThemeFile)
const preloadedThemePath = path.join(dir, preloadedThemeFile)
const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
const fnMarker = path.join(dir, "function-called.txt")
const localMarker = path.join(dir, "local-called.json")
const invalidMarker = path.join(dir, "invalid-called.json")
const globalMarker = path.join(dir, "global-called.json")
const preloadedMarker = path.join(dir, "preloaded-called.json")
const localConfigPath = path.join(dir, "tui.json")
await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
await Bun.write(invalidThemePath, "{ invalid json }")
await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
await Bun.write(
localPluginPath,
`export default async (_input, options) => {
if (!options?.fn_marker) return
await Bun.write(options.fn_marker, "called")
}
export const object_plugin = {
tui: async (input, options, init) => {
if (!options?.marker) return
const key = input.api.keybind.create(
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
options.keybinds,
)
const depth_before = input.api.ui.dialog.depth
const open_before = input.api.ui.dialog.open
const size_before = input.api.ui.dialog.size
input.api.ui.dialog.setSize("large")
const size_after = input.api.ui.dialog.size
input.api.ui.dialog.replace(() => null)
const depth_after = input.api.ui.dialog.depth
const open_after = input.api.ui.dialog.open
input.api.ui.dialog.clear()
const open_clear = input.api.ui.dialog.open
const before = input.api.theme.has(options.theme_name)
const set_missing = input.api.theme.set(options.theme_name)
await input.api.theme.install(options.theme_path)
const after = input.api.theme.has(options.theme_name)
const set_installed = input.api.theme.set(options.theme_name)
const first = await Bun.file(options.dest).text()
await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
await input.api.theme.install(options.theme_path)
const second = await Bun.file(options.dest).text()
const init_state = init.state
const init_source = init.entry.source
const init_load_count = init.entry.load_count
await Bun.write(
options.marker,
JSON.stringify({
before,
set_missing,
after,
set_installed,
selected: input.api.theme.selected,
same: first === second,
key_modal: key.get("modal"),
key_close: key.get("close"),
key_unknown: key.get("ctrl+k"),
key_print: key.print("modal"),
depth_before,
open_before,
size_before,
size_after,
depth_after,
open_after,
open_clear,
init_state,
init_source,
init_load_count,
}),
)
},
}
`,
)
await Bun.write(
invalidPluginPath,
`export default {
tui: async (input, options) => {
if (!options?.marker) return
const before = input.api.theme.has(options.theme_name)
const set_missing = input.api.theme.set(options.theme_name)
await input.api.theme.install(options.theme_path)
const after = input.api.theme.has(options.theme_name)
const set_installed = input.api.theme.set(options.theme_name)
await Bun.write(
options.marker,
JSON.stringify({
before,
set_missing,
after,
set_installed,
}),
)
},
}
`,
)
await Bun.write(
preloadedPluginPath,
`export default {
tui: async (input, options, init) => {
if (!options?.marker) return
const before = input.api.theme.has(options.theme_name)
await input.api.theme.install(options.theme_path)
const after = input.api.theme.has(options.theme_name)
const text = await Bun.file(options.dest).text()
await Bun.write(
options.marker,
JSON.stringify({
before,
after,
text,
init_state: init.state,
init_source: init.entry.source,
init_load_count: init.entry.load_count,
}),
)
},
}
`,
)
await Bun.write(
globalPluginPath,
`export default {
tui: async (input, options, init) => {
if (!options?.marker) return
await input.api.theme.install(options.theme_path)
const has = input.api.theme.has(options.theme_name)
const set_installed = input.api.theme.set(options.theme_name)
await Bun.write(
options.marker,
JSON.stringify({
has,
set_installed,
selected: input.api.theme.selected,
init_state: init.state,
init_source: init.entry.source,
init_load_count: init.entry.load_count,
}),
)
},
}
`,
)
await Bun.write(
globalConfigPath,
JSON.stringify(
{
plugin: [
[globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
],
},
null,
2,
),
)
await Bun.write(
localConfigPath,
JSON.stringify(
{
plugin: [
[
localSpec,
{
fn_marker: fnMarker,
marker: localMarker,
source: localThemePath,
dest: localDest,
theme_path: `./${localThemeFile}`,
theme_name: localThemeName,
keybinds: {
modal: "ctrl+alt+m",
close: "q",
},
},
],
[
invalidSpec,
{
marker: invalidMarker,
theme_path: `./${invalidThemeFile}`,
theme_name: invalidThemeName,
},
],
[
preloadedSpec,
{
marker: preloadedMarker,
dest: preloadedDest,
theme_path: `./${preloadedThemeFile}`,
theme_name: preloadedThemeName,
},
],
],
},
null,
2,
),
)
return {
localThemeFile,
invalidThemeFile,
globalThemeFile,
preloadedThemeFile,
localThemeName,
invalidThemeName,
globalThemeName,
preloadedThemeName,
localDest,
globalDest,
preloadedDest,
localPluginPath,
invalidPluginPath,
globalPluginPath,
preloadedPluginPath,
localSpec,
invalidSpec,
globalSpec,
preloadedSpec,
fnMarker,
localMarker,
invalidMarker,
globalMarker,
preloadedMarker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
if (!process.env.OPENCODE_PLUGIN_META_FILE) throw new Error("missing meta file")
await PluginMeta.touch(tmp.extra.localSpec, tmp.extra.localSpec)
await PluginMeta.touch(tmp.extra.globalSpec, tmp.extra.globalSpec)
await PluginMeta.persist()
await Bun.sleep(20)
const text = await Bun.file(tmp.extra.globalPluginPath).text()
await Bun.write(tmp.extra.globalPluginPath, `${text}\n`)
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
let selected = "opencode"
let depth = 0
let size: "medium" | "large" = "medium"
const renderer = {
...Object.create(null),
once(this: CliRenderer) {
return this
},
} satisfies CliRenderer
const keybind = {
parse: (evt: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) => ({
name: evt.name ?? "",
ctrl: evt.ctrl ?? false,
meta: evt.meta ?? false,
shift: evt.shift ?? false,
super: evt.super,
leader: false,
}),
match: () => false,
print: (key: string) => `print:${key}`,
}
try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
await TuiPlugin.init({
client: createOpencodeClient({
baseUrl: "http://localhost:4096",
}),
event: {
on: () => () => {},
},
renderer,
api: {
command: {
register: () => {},
trigger: () => {},
},
route: {
register: () => () => {},
navigate: () => {},
get current() {
return { name: "home" as const }
},
},
ui: {
Dialog: () => null,
DialogAlert: () => null,
DialogConfirm: () => null,
DialogPrompt: () => null,
DialogSelect: () => null,
toast: () => {},
dialog: {
replace: () => {
depth = 1
},
clear: () => {
depth = 0
size = "medium"
},
setSize: (next) => {
size = next
},
get size() {
return size
},
get depth() {
return depth
},
get open() {
return depth > 0
},
},
},
keybind: {
...keybind,
create(defaults, overrides) {
return createPluginKeybind(keybind, defaults, overrides)
},
},
theme: {
get current() {
return {}
},
get selected() {
return selected
},
has(name) {
return allThemes()[name] !== undefined
},
set(name) {
if (!allThemes()[name]) return false
selected = name
return true
},
async install() {
throw new Error("base theme.install should not run")
},
mode() {
return "dark" as const
},
get ready() {
return true
},
},
},
})
const local = JSON.parse(await fs.readFile(tmp.extra.localMarker, "utf8"))
expect(local.before).toBe(false)
expect(local.set_missing).toBe(false)
expect(local.after).toBe(true)
expect(local.set_installed).toBe(true)
expect(local.selected).toBe(tmp.extra.localThemeName)
expect(local.same).toBe(true)
expect(local.key_modal).toBe("ctrl+alt+m")
expect(local.key_close).toBe("q")
expect(local.key_unknown).toBe("ctrl+k")
expect(local.key_print).toBe("print:ctrl+alt+m")
expect(local.depth_before).toBe(0)
expect(local.open_before).toBe(false)
expect(local.size_before).toBe("medium")
expect(local.size_after).toBe("large")
expect(local.depth_after).toBe(1)
expect(local.open_after).toBe(true)
expect(local.open_clear).toBe(false)
expect(local.init_state).toBe("same")
expect(local.init_source).toBe("file")
expect(local.init_load_count).toBe(2)
const global = JSON.parse(await fs.readFile(tmp.extra.globalMarker, "utf8"))
expect(global.has).toBe(true)
expect(global.set_installed).toBe(true)
expect(global.selected).toBe(tmp.extra.globalThemeName)
expect(global.init_state).toBe("updated")
expect(global.init_source).toBe("file")
expect(global.init_load_count).toBe(2)
const invalid = JSON.parse(await fs.readFile(tmp.extra.invalidMarker, "utf8"))
expect(invalid.before).toBe(false)
expect(invalid.set_missing).toBe(false)
expect(invalid.after).toBe(false)
expect(invalid.set_installed).toBe(false)
const preloaded = JSON.parse(await fs.readFile(tmp.extra.preloadedMarker, "utf8"))
expect(preloaded.before).toBe(true)
expect(preloaded.after).toBe(true)
expect(preloaded.text).toContain("#303030")
expect(preloaded.text).not.toContain("#f0f0f0")
expect(preloaded.init_state).toBe("first")
expect(preloaded.init_source).toBe("file")
expect(preloaded.init_load_count).toBe(1)
await expect(fs.readFile(tmp.extra.fnMarker, "utf8")).rejects.toThrow()
const localInstalled = await fs.readFile(tmp.extra.localDest, "utf8")
expect(localInstalled).toContain("#101010")
expect(localInstalled).not.toContain("#fefefe")
const globalInstalled = await fs.readFile(tmp.extra.globalDest, "utf8")
expect(globalInstalled).toContain("#202020")
const preloadedInstalled = await fs.readFile(tmp.extra.preloadedDest, "utf8")
expect(preloadedInstalled).toContain("#303030")
expect(preloadedInstalled).not.toContain("#f0f0f0")
expect(
await fs
.stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
.then(() => true)
.catch(() => false),
).toBe(false)
expect(
await fs
.stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
.then(() => true)
.catch(() => false),
).toBe(false)
const log = await waitForLog("ignoring non-object tui plugin export")
expect(log).toContain("ignoring non-object tui plugin export")
expect(log).toContain("name=default")
expect(log).toContain("type=function")
const badThemeLog = await waitForLog("failed to parse tui plugin theme")
expect(badThemeLog).toContain("failed to parse tui plugin theme")
expect(badThemeLog).toContain(tmp.extra.invalidThemeFile)
const meta = JSON.parse(await fs.readFile(path.join(tmp.path, "plugin-meta.json"), "utf8")) as Record<
string,
{ name: string; load_count: number }
>
const rows = Object.values(meta)
expect(rows.find((item) => item.name === "local-plugin")?.load_count).toBe(2)
expect(rows.find((item) => item.name === "global-plugin")?.load_count).toBe(2)
expect(rows.find((item) => item.name === "preloaded-plugin")?.load_count).toBe(1)
} finally {
cwd.mockRestore()
if (backup === undefined) {
await fs.rm(globalConfigPath, { force: true })
} else {
await Bun.write(globalConfigPath, backup)
}
await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -0,0 +1,44 @@
import { expect, mock, test } from "bun:test"
mock.module("@opentui/solid/jsx-runtime", () => ({
Fragment: Symbol.for("Fragment"),
jsx: () => null,
jsxs: () => null,
jsxDEV: () => null,
}))
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme } = await import("../../../src/cli/cmd/tui/context/theme")
test("addTheme writes into module theme store", () => {
const name = `plugin-theme-${Date.now()}`
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
expect(allThemes()[name]).toBeDefined()
})
test("addTheme keeps first theme for duplicate names", () => {
const name = `plugin-theme-keep-${Date.now()}`
const one = structuredClone(DEFAULT_THEMES.opencode)
const two = structuredClone(DEFAULT_THEMES.opencode)
;(one.theme as Record<string, unknown>).primary = "#101010"
;(two.theme as Record<string, unknown>).primary = "#fefefe"
expect(addTheme(name, one)).toBe(true)
expect(addTheme(name, two)).toBe(false)
expect(allThemes()[name]).toBeDefined()
expect(allThemes()[name]!.theme.primary).toBe("#101010")
})
test("addTheme ignores entries without a theme object", () => {
const name = `plugin-theme-invalid-${Date.now()}`
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
expect(allThemes()[name]).toBeUndefined()
})
test("hasTheme checks theme presence", () => {
const name = `plugin-theme-has-${Date.now()}`
expect(hasTheme(name)).toBe(false)
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
expect(hasTheme(name)).toBe(true)
})

View File

@@ -1809,7 +1809,7 @@ describe("deduplicatePlugins", () => {
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
expect(myPlugins.length).toBe(1)
expect(myPlugins[0].startsWith("file://")).toBe(true)
expect(Config.pluginSpecifier(myPlugins[0]).startsWith("file://")).toBe(true)
},
})
})

View File

@@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li
test("loads managed tui config and gives it highest precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2),
)
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
await Bun.write(
path.join(managedConfigDir, "tui.json"),
JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
)
},
})
@@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
expect(config.plugin_meta).toEqual({
"shared-plugin": {
scope: "global",
source: path.join(managedConfigDir, "tui.json"),
},
})
},
})
})
@@ -508,3 +521,110 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
},
})
})
test("supports tuple plugin specs with options in tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
expect(config.plugin_meta).toEqual({
"acme-plugin": {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
})
},
})
})
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(Global.Path.config, "tui.json"),
JSON.stringify({
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
}),
)
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
plugin: [
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual([
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
])
expect(config.plugin_meta).toEqual({
"acme-plugin": {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
"second-plugin": {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
})
},
})
})
test("tracks global and local plugin metadata in merged tui config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(Global.Path.config, "tui.json"),
JSON.stringify({
plugin: ["global-plugin@1.0.0"],
}),
)
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
plugin: ["local-plugin@2.0.0"],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
expect(config.plugin_meta).toEqual({
"global-plugin": {
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
"local-plugin": {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
})
},
})
})

View File

@@ -0,0 +1,306 @@
import { afterAll, afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { Instance } = await import("../../src/project/instance")
const { BunProc } = await import("../../src/bun")
const { Bus } = await import("../../src/bus")
const { Session } = await import("../../src/session")
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
})
async function load(dir: string) {
return Instance.provide({
directory: dir,
fn: async () => {
await Plugin.list()
},
})
}
async function errs(dir: string) {
return Instance.provide({
directory: dir,
fn: async () => {
const errors: string[] = []
const off = Bus.subscribe(Session.Event.Error, (evt) => {
const error = evt.properties.error
if (!error || typeof error !== "object") return
if (!("data" in error)) return
if (!error.data || typeof error.data !== "object") return
if (!("message" in error.data)) return
if (typeof error.data.message !== "string") return
errors.push(error.data.message)
})
await Plugin.list()
off()
return errors
},
})
}
describe("plugin.loader.shared", () => {
test("loads a file:// plugin function export", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default async () => {",
` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
await load(tmp.path)
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
})
test("deduplicates same function exported as default and named", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "count.txt")
await Bun.write(
file,
[
"const run = async () => {",
` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => \"\")`,
` await Bun.write(${JSON.stringify(mark)}, text + \"1\")`,
" return {}",
"}",
"export default run",
"export const named = run",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
await load(tmp.path)
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1")
})
test("resolves npm plugin specs with explicit and default versions", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
await Bun.write(file, ["export default async () => {", " return {}", "}", ""].join("\n"))
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2),
)
return { file }
},
})
const install = spyOn(BunProc, "install").mockImplementation(async () => pathToFileURL(tmp.extra.file).href)
await load(tmp.path)
expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"])
expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"])
})
test("skips legacy codex and copilot auth plugin specs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
},
null,
2,
),
)
},
})
const install = spyOn(BunProc, "install").mockResolvedValue("")
await load(tmp.path)
const pkgs = install.mock.calls.map((call) => call[0])
expect(pkgs).toContain("regular-plugin")
expect(pkgs).not.toContain("opencode-openai-codex-auth")
expect(pkgs).not.toContain("opencode-copilot-auth")
})
test("publishes session.error when install fails", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
},
})
spyOn(BunProc, "install").mockRejectedValue(new Error("boom"))
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
true,
)
})
test("publishes session.error when plugin init throws", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = pathToFileURL(path.join(dir, "throws.ts")).href
await Bun.write(
path.join(dir, "throws.ts"),
["export default async () => {", ' throw new Error("explode")', "}", ""].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
return { file }
},
})
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
})
test("publishes session.error when plugin module has invalid export", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
await Bun.write(
path.join(dir, "invalid.ts"),
["export default async () => {", " return {}", "}", 'export const meta = { name: "invalid" }', ""].join(
"\n",
),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
return { file }
},
})
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
})
test("publishes session.error when plugin import fails", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
return { missing }
},
})
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
})
test("loads object plugin via plugin.server", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "object-plugin.ts")
const mark = path.join(dir, "object-called.txt")
await Bun.write(
file,
[
"const plugin = {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, \"called\")`,
" return {}",
" },",
"}",
"export default plugin",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
await load(tmp.path)
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
})
test("passes tuple plugin options into server plugin", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "options-plugin.ts")
const mark = path.join(dir, "options.json")
await Bun.write(
file,
[
"const plugin = {",
" server: async (_input, options) => {",
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
" return {}",
" },",
"}",
"export default plugin",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
)
return { mark }
},
})
await load(tmp.path)
expect(JSON.parse(await fs.readFile(tmp.extra.mark, "utf8"))).toEqual({ source: "tuple", enabled: true })
})
})

View File

@@ -0,0 +1,87 @@
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
const { PluginMeta } = await import("../../src/plugin/meta")
afterEach(() => {
delete process.env.OPENCODE_PLUGIN_META_FILE
})
describe("plugin.meta", () => {
test("tracks file plugin loads and changes", async () => {
await using tmp = await tmpdir<{ file: string }>({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
await Bun.write(file, "export default async () => ({})\n")
return { file }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
const file = process.env.OPENCODE_PLUGIN_META_FILE!
const spec = pathToFileURL(tmp.extra.file).href
const one = await PluginMeta.touch(spec, spec)
expect(one.state).toBe("first")
expect(one.entry.source).toBe("file")
expect(one.entry.modified).toBeDefined()
const two = await PluginMeta.touch(spec, spec)
expect(two.state).toBe("same")
expect(two.entry.load_count).toBe(2)
await Bun.sleep(20)
await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n")
const three = await PluginMeta.touch(spec, spec)
expect(three.state).toBe("updated")
expect(three.entry.load_count).toBe(3)
expect((three.entry.modified ?? 0) >= (one.entry.modified ?? 0)).toBe(true)
await expect(fs.readFile(file, "utf8")).rejects.toThrow()
await PluginMeta.persist()
const all = await PluginMeta.list()
expect(Object.values(all).some((item) => item.spec === spec && item.source === "file")).toBe(true)
const saved = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, { spec: string; load_count: number }>
expect(Object.values(saved).some((item) => item.spec === spec && item.load_count === 3)).toBe(true)
})
test("tracks npm plugin versions", async () => {
await using tmp = await tmpdir<{ mod: string; pkg: string }>({
init: async (dir) => {
const mod = path.join(dir, "node_modules", "acme-plugin")
const pkg = path.join(mod, "package.json")
await fs.mkdir(mod, { recursive: true })
await Bun.write(pkg, JSON.stringify({ name: "acme-plugin", version: "1.0.0" }, null, 2))
return { mod, pkg }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
const file = process.env.OPENCODE_PLUGIN_META_FILE!
const one = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod)
expect(one.state).toBe("first")
expect(one.entry.source).toBe("npm")
expect(one.entry.requested).toBe("latest")
expect(one.entry.version).toBe("1.0.0")
await Bun.write(tmp.extra.pkg, JSON.stringify({ name: "acme-plugin", version: "1.1.0" }, null, 2))
const two = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod)
expect(two.state).toBe("updated")
expect(two.entry.version).toBe("1.1.0")
expect(two.entry.load_count).toBe(2)
await PluginMeta.persist()
const all = await PluginMeta.list()
expect(Object.values(all).some((item) => item.name === "acme-plugin" && item.version === "1.1.0")).toBe(true)
const saved = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, { name: string; version?: string }>
expect(Object.values(saved).some((item) => item.name === "acme-plugin" && item.version === "1.1.0")).toBe(true)
})
})

View File

@@ -10,7 +10,8 @@
},
"exports": {
".": "./src/index.ts",
"./tool": "./src/tool.ts"
"./tool": "./src/tool.ts",
"./tui": "./src/tui.ts"
},
"files": [
"dist"
@@ -19,7 +20,16 @@
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.1.87"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
}
},
"devDependencies": {
"@opentui/core": "0.0.0-20260318-39211c40",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",

View File

@@ -9,7 +9,7 @@ import type {
Message,
Part,
Auth,
Config,
Config as SDKConfig,
} from "@opencode-ai/sdk"
import type { BunShell } from "./shell.js"
@@ -32,7 +32,13 @@ export type PluginInput = {
$: BunShell
}
export type Plugin = (input: PluginInput) => Promise<Hooks>
export type PluginOptions = Record<string, unknown>
export type Config = Omit<SDKConfig, "plugin"> & {
plugin?: Array<string | [string, PluginOptions]>
}
export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
type Rule = {
key: string

320
packages/plugin/src/tui.ts Normal file
View File

@@ -0,0 +1,320 @@
import type {
createOpencodeClient as createOpencodeClientV2,
Event as TuiEvent,
LspStatus,
McpStatus,
Todo,
} from "@opencode-ai/sdk/v2"
import type { CliRenderer, ParsedKey, Plugin as CorePlugin } from "@opentui/core"
import type { Plugin as ServerPlugin, PluginOptions } from "./index.js"
export type { CliRenderer, SlotMode } from "@opentui/core"
export type TuiRouteCurrent =
| {
name: "home"
}
| {
name: "session"
params: {
sessionID: string
initialPrompt?: unknown
}
}
| {
name: string
params?: Record<string, unknown>
}
export type TuiRouteDefinition<Node = unknown> = {
name: string
render: (input: { params?: Record<string, unknown> }) => Node
}
export type TuiCommand = {
title: string
value: string
description?: string
category?: string
keybind?: string
suggested?: boolean
hidden?: boolean
enabled?: boolean
slash?: {
name: string
aliases?: string[]
}
onSelect?: () => void
}
export type TuiKeybind = {
name: string
ctrl: boolean
meta: boolean
shift: boolean
super?: boolean
leader: boolean
}
export type TuiKeybindMap = Record<string, string>
export type TuiKeybindSet = {
readonly all: TuiKeybindMap
get: (name: string) => string
parse: (evt: ParsedKey) => TuiKeybind
match: (name: string, evt: ParsedKey) => boolean
print: (name: string) => string
}
export type TuiDialogProps<Node = unknown> = {
size?: "medium" | "large"
onClose: () => void
children?: Node
}
export type TuiDialogStack<Node = unknown> = {
replace: (render: () => Node, onClose?: () => void) => void
clear: () => void
setSize: (size: "medium" | "large") => void
readonly size: "medium" | "large"
readonly depth: number
readonly open: boolean
}
export type TuiDialogAlertProps = {
title: string
message: string
onConfirm?: () => void
}
export type TuiDialogConfirmProps = {
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
}
export type TuiDialogPromptProps<Node = unknown> = {
title: string
description?: () => Node
placeholder?: string
value?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
export type TuiDialogSelectOption<Value = unknown, Node = unknown> = {
title: string
value: Value
description?: string
footer?: Node | string
category?: string
disabled?: boolean
onSelect?: () => void
}
export type TuiDialogSelectProps<Value = unknown, Node = unknown> = {
title: string
placeholder?: string
options: TuiDialogSelectOption<Value, Node>[]
flat?: boolean
onMove?: (option: TuiDialogSelectOption<Value, Node>) => void
onFilter?: (query: string) => void
onSelect?: (option: TuiDialogSelectOption<Value, Node>) => void
skipFilter?: boolean
current?: Value
}
export type TuiToast = {
variant?: "info" | "success" | "warning" | "error"
title?: string
message: string
duration?: number
}
export type TuiTheme = {
readonly current: Record<string, unknown>
readonly selected: string
has: (name: string) => boolean
set: (name: string) => boolean
install: (jsonPath: string) => Promise<void>
mode: () => "dark" | "light"
readonly ready: boolean
}
export type TuiApi<Node = unknown> = {
command: {
register: (cb: () => TuiCommand[]) => void
trigger: (value: string) => void
}
route: {
register: (routes: TuiRouteDefinition<Node>[]) => () => void
navigate: (name: string, params?: Record<string, unknown>) => void
readonly current: TuiRouteCurrent
}
ui: {
Dialog: (props: TuiDialogProps<Node>) => Node
DialogAlert: (props: TuiDialogAlertProps) => Node
DialogConfirm: (props: TuiDialogConfirmProps) => Node
DialogPrompt: (props: TuiDialogPromptProps<Node>) => Node
DialogSelect: <Value = unknown>(props: TuiDialogSelectProps<Value, Node>) => Node
toast: (input: TuiToast) => void
dialog: TuiDialogStack<Node>
}
keybind: {
parse: (evt: ParsedKey) => TuiKeybind
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
}
theme: TuiTheme
}
export type TuiSidebarMcpItem = {
name: string
status: McpStatus["status"]
error?: string
}
export type TuiSidebarLspItem = Pick<LspStatus, "id" | "root" | "status">
export type TuiSidebarTodoItem = Pick<Todo, "content" | "status">
export type TuiSidebarFileItem = {
file: string
additions: number
deletions: number
}
export type TuiSlotMap = {
app: {}
home_logo: {}
home_tips: {
show_tips: boolean
tips_hidden: boolean
first_time_user: boolean
}
home_below_tips: {
show_tips: boolean
tips_hidden: boolean
first_time_user: boolean
}
sidebar_top: {
session_id: string
}
sidebar_title: {
session_id: string
title: string
share_url?: string
}
sidebar_context: {
session_id: string
tokens: number
percentage: number | null
cost: number
}
sidebar_mcp: {
session_id: string
items: TuiSidebarMcpItem[]
connected: number
errors: number
}
sidebar_lsp: {
session_id: string
items: TuiSidebarLspItem[]
disabled: boolean
}
sidebar_todo: {
session_id: string
items: TuiSidebarTodoItem[]
}
sidebar_files: {
session_id: string
items: TuiSidebarFileItem[]
}
sidebar_getting_started: {
session_id: string
show_getting_started: boolean
has_providers: boolean
dismissed: boolean
}
sidebar_directory: {
session_id: string
directory: string
directory_parent: string
directory_name: string
}
sidebar_version: {
session_id: string
version: string
}
sidebar_bottom: {
session_id: string
directory: string
directory_parent: string
directory_name: string
version: string
show_getting_started: boolean
has_providers: boolean
dismissed: boolean
}
}
export type TuiSlotContext = {
theme: TuiTheme
}
export type TuiSlotPlugin<Node = unknown> = CorePlugin<Node, TuiSlotMap, TuiSlotContext>
export type TuiSlots = {
register: (plugin: TuiSlotPlugin) => () => void
}
export type TuiEventBus = {
on: <Type extends TuiEvent["type"]>(
type: Type,
handler: (event: Extract<TuiEvent, { type: Type }>) => void,
) => () => void
}
export type TuiPluginState = "first" | "updated" | "same"
export type TuiPluginMeta = {
name: string
source: "file" | "npm"
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
}
export type TuiPluginInit = {
state: TuiPluginState
entry: TuiPluginMeta
}
export type TuiPluginInput<Renderer = CliRenderer, Node = unknown> = {
client: ReturnType<typeof createOpencodeClientV2>
event: TuiEventBus
renderer: Renderer
slots: TuiSlots
api: TuiApi<Node>
}
export type TuiPlugin<Renderer = CliRenderer, Node = unknown> = (
input: TuiPluginInput<Renderer, Node>,
options: PluginOptions | null,
init: TuiPluginInit,
) => Promise<void>
export type TuiPluginModule<Renderer = CliRenderer, Node = unknown> = {
server?: ServerPlugin
tui?: TuiPlugin<Renderer, Node>
slots?: TuiSlotPlugin
}

View File

@@ -1342,7 +1342,15 @@ export type Config = {
watcher?: {
ignore?: Array<string>
}
plugin?: Array<string>
plugin?: Array<
| string
| [
string,
{
[key: string]: unknown
},
]
>
/**
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
*/

View File

@@ -307,8 +307,6 @@ For custom inference profiles, use the model and provider name in the key and se
```txt
┌ Select auth method
│ Claude Pro/Max
│ Create an API Key
│ Manually enter API Key
```
@@ -320,14 +318,19 @@ For custom inference profiles, use the model and provider name in the key and se
```
:::info
Using your Claude Pro/Max subscription in OpenCode is not officially supported by [Anthropic](https://anthropic.com).
:::
There are plugins that allow you to use your Claude Pro/Max models with
OpenCode. Anthropic explicitly prohibits this.
##### Using API keys
Previous versions of OpenCode came bundled with these plugins but that is no
longer the case as of 1.3.0
You can also select **Create an API Key** if you don't have a Pro/Max subscription. It'll also open your browser and ask you to login to Anthropic and give you a code you can paste in your terminal.
Other companies support freedom of choice with developer tooling - you can use
the following subscriptions in OpenCode with zero setup:
Or if you already have an API key, you can select **Manually enter API Key** and paste it in your terminal.
- ChatGPT Plus
- Github Copilot
- Gitlab Duo
:::
---