Compare commits

..

40 Commits

Author SHA1 Message Date
Sebastian Herrlinger
c60216c92b demo theme contrasts 2026-03-05 22:21:28 +01:00
Sebastian Herrlinger
bcb355d6aa theme colors 2026-03-05 20:21:01 +01:00
Sebastian Herrlinger
c922f54051 smoke cube 2026-03-05 20:21:01 +01:00
Sebastian Herrlinger
701f62fc41 btn 2026-03-05 20:21:01 +01:00
Sebastian Herrlinger
4c3eb84c51 post processing 2026-03-05 20:21:01 +01:00
Sebastian Herrlinger
b1df662ac5 ascii logo for smoke test 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
7ed6a55bb5 theming 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
c5e45010f1 extend demo 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
881a8c7e6f refine 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
a5e7c7396f stash 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
34d557e806 remove log file 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
36d5c72eb1 only check outdated when online 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
d5047bdd4c load file plugins immediately 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
942e21a0d1 refine demo plugin 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
41e103ce65 STASH COMPLEX PLUGIN 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
3889473f41 cleanup 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
a1ce12ea4f stash 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
c5d8ea11f8 types 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
5141ad7b48 cleanup sdk 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
f37c4539fe cleanup sdk provider 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
08086eba9d align 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
4e39cd74ab dedupe 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
a60bb23cd1 naming stuff 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
347be15e40 types 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
3178650d11 untangle 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
38eaa69ee0 tui plugin object only 2026-03-05 20:21:00 +01:00
Sebastian Herrlinger
441ca97c23 use opentui types 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
f871b0ebe4 no url or dir 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
447298f930 separate tui from server plugin 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
a145180cba remove custom jsx plumbing 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
c96351c3fe use build mode 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
cc9f69e2e2 map for ap 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
056bec4c91 versions 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
1ab43da819 types regen 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
895529d000 update 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
4389ebd930 STASH 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
e0a70cff8f ugly but works 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
dc25912308 debug 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
f580643e80 smoke 2026-03-05 20:20:59 +01:00
Sebastian Herrlinger
d48d546d97 stash 2026-03-05 20:20:59 +01:00
105 changed files with 3007 additions and 1438 deletions

View File

@@ -0,0 +1,775 @@
/** @jsxImportSource @opentui/solid */
import mytheme from "../themes/mytheme.json" with { type: "json" }
import { extend, useKeyboard, useTerminalDimensions, type RenderableConstructor } from "@opentui/solid"
import { RGBA, VignetteEffect, type OptimizedBuffer, type RenderContext } from "@opentui/core"
import { ThreeRenderable, THREE } from "@opentui/core/3d"
import type { TuiApi, TuiPluginInput } from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
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 cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
modal: pick(options?.modal, "ctrl+shift+m"),
screen: pick(options?.screen, "ctrl+shift+o"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
}
}
const names = (input: ReturnType<typeof cfg>) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
alert: `${input.route}.alert`,
confirm: `${input.route}.confirm`,
prompt: `${input.route}.prompt`,
select: `${input.route}.select`,
}
}
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const tone = (api: TuiApi) => {
const map = api.theme.current as Record<string, unknown>
const get = (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
}
return {
panel: get("backgroundPanel", ui.panel),
border: get("border", ui.border),
text: get("text", ui.text),
muted: get("textMuted", ui.muted),
accent: get("primary", ui.accent),
selected: get("selectedListItemText", ui.text),
}
}
type Skin = ReturnType<typeof tone>
type CubeOpts = ConstructorParameters<typeof ThreeRenderable>[1] & {
tint?: Color
spec?: Color
ambient?: Color
key_light?: Color
fill_light?: Color
}
const rgb = (value: unknown, fallback: string) => {
if (typeof value === "string") return new THREE.Color(value)
if (value && typeof value === "object") {
const item = value as { r?: unknown; g?: unknown; b?: unknown }
if (typeof item.r === "number" && typeof item.g === "number" && typeof item.b === "number") {
return new THREE.Color(item.r, item.g, item.b)
}
}
return new THREE.Color(fallback)
}
class Cube extends ThreeRenderable {
private cube: THREE.Mesh
private mat: THREE.MeshPhongMaterial
private amb: THREE.AmbientLight
private key: THREE.DirectionalLight
private fill: THREE.DirectionalLight
constructor(ctx: RenderContext, opts: CubeOpts) {
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100)
camera.position.set(0, 0, 2.55)
const amb = new THREE.AmbientLight(rgb(opts.ambient, "#666666"), 1.0)
scene.add(amb)
const key = new THREE.DirectionalLight(rgb(opts.key_light, "#fff2e6"), 1.2)
key.position.set(2.5, 2.0, 3.0)
scene.add(key)
const fill = new THREE.DirectionalLight(rgb(opts.fill_light, "#80b3ff"), 0.6)
fill.position.set(-2.0, -1.5, 2.5)
scene.add(fill)
const geo = new THREE.BoxGeometry(1.0, 1.0, 1.0)
const mat = new THREE.MeshPhongMaterial({
color: rgb(opts.tint, "#40ccff"),
shininess: 80,
specular: rgb(opts.spec, "#e6e6ff"),
})
const cube = new THREE.Mesh(geo, mat)
cube.scale.setScalar(1.12)
scene.add(cube)
super(ctx, {
...opts,
scene,
camera,
renderer: {
focalLength: 8,
alpha: true,
backgroundColor: RGBA.fromValues(0, 0, 0, 0),
},
})
this.cube = cube
this.mat = mat
this.amb = amb
this.key = key
this.fill = fill
}
set tint(value: Color | undefined) {
this.mat.color.copy(rgb(value, "#40ccff"))
}
set spec(value: Color | undefined) {
this.mat.specular.copy(rgb(value, "#e6e6ff"))
}
set ambient(value: Color | undefined) {
this.amb.color.copy(rgb(value, "#666666"))
}
set key_light(value: Color | undefined) {
this.key.color.copy(rgb(value, "#fff2e6"))
}
set fill_light(value: Color | undefined) {
this.fill.color.copy(rgb(value, "#80b3ff"))
}
protected override renderSelf(buf: OptimizedBuffer, dt: number): void {
const delta = dt / 1000
this.cube.rotation.x += delta * 0.6
this.cube.rotation.y += delta * 0.4
this.cube.rotation.z += delta * 0.2
super.renderSelf(buf, dt)
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
smoke_cube: RenderableConstructor
}
}
extend({ smoke_cube: Cube as unknown as RenderableConstructor })
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 : ""
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
}
}
const current = (api: TuiApi, route: ReturnType<typeof names>) => {
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 Screen = (props: {
api: TuiApi
input: ReturnType<typeof cfg>
route: ReturnType<typeof names>
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (evt.name === "escape" || (evt.ctrl && evt.name === "h")) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (evt.ctrl && evt.name === "m") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (evt.name === "a") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.alert, next)
return
}
if (evt.name === "c") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.confirm, next)
return
}
if (evt.name === "p") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.prompt, next)
return
}
if (evt.name === "s") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.select, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel}>
<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}>esc or ctrl+h 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}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>up/down or j/k change value</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>ctrl+m modal | a alert | c confirm | p prompt | s select</text>
<text fg={skin.muted}>esc or ctrl+h 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="alert" run={() => props.api.route.navigate(props.route.alert, value)} skin={skin} />
<Btn txt="confirm" run={() => props.api.route.navigate(props.route.confirm, value)} skin={skin} />
<Btn txt="prompt" run={() => props.api.route.navigate(props.route.prompt, value)} skin={skin} />
<Btn txt="select" run={() => props.api.route.navigate(props.route.select, value)} skin={skin} />
</box>
</box>
</box>
)
}
const Modal = (props: {
api: TuiApi
input: ReturnType<typeof cfg>
route: ReturnType<typeof names>
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 (evt.name === "return" || evt.name === "enter") {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (evt.name === "escape") {
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.api.keybind.print(props.input.modal)} modal command</text>
<text fg={skin.muted}>{props.api.keybind.print(props.input.screen)} screen command</text>
<text fg={skin.muted}>enter opens screen · esc 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 AlertDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
const Dialog = props.api.ui.Dialog
const DialogAlert = props.api.ui.DialogAlert
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.alert) return
if (evt.name !== "escape") return
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, value)
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate(props.route.screen, value)}>
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => props.api.route.navigate(props.route.screen, { ...value, source: "alert" })}
/>
</Dialog>
</box>
)
}
const ConfirmDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
const Dialog = props.api.ui.Dialog
const DialogConfirm = props.api.ui.DialogConfirm
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.confirm) return
if (evt.name !== "escape") return
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, value)
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate(props.route.screen, value)}>
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() =>
props.api.route.navigate(props.route.screen, { ...value, count: value.count + 1, source: "confirm" })
}
onCancel={() => props.api.route.navigate(props.route.screen, { ...value, source: "confirm-cancel" })}
/>
</Dialog>
</box>
)
}
const PromptDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
const Dialog = props.api.ui.Dialog
const DialogPrompt = props.api.ui.DialogPrompt
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.prompt) return
if (evt.name !== "escape") return
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, value)
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate(props.route.screen, value)}>
<DialogPrompt
title="Smoke prompt"
description={() => <text fg={skin.muted}>Enter a note to store in route params</text>}
value={value.note}
onConfirm={(note) => props.api.route.navigate(props.route.screen, { ...value, note, source: "prompt" })}
onCancel={() => props.api.route.navigate(props.route.screen, value)}
/>
</Dialog>
</box>
)
}
const SelectDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
const Dialog = props.api.ui.Dialog
const DialogSelect = props.api.ui.DialogSelect
const value = parse(props.params)
const skin = tone(props.api)
const options = [
{
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",
},
]
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.select) return
if (evt.name !== "escape") return
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, value)
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate(props.route.screen, value)}>
<DialogSelect
title="Smoke select"
options={options}
current={value.tab}
onSelect={(item) =>
props.api.route.navigate(props.route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}
/>
</Dialog>
</box>
)
}
const slot = (input: ReturnType<typeof cfg>) => ({
id: "workspace-smoke",
slots: {
home_logo(ctx) {
const map = ctx.theme.current as Record<string, unknown>
const get = (name: string, fallback: string) => {
const value = map[name]
if (typeof value === "string") return value
if (value && typeof value === "object") return value as RGBA
return fallback
}
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const ink = [
get("primary", ui.accent),
get("textMuted", ui.muted),
get("info", ui.accent),
get("text", ui.text),
get("success", ui.accent),
get("warning", ui.accent),
get("secondary", ui.accent),
get("error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={ink[i]}>{line}</text>
))}
</box>
)
},
sidebar_top(ctx, value) {
const map = ctx.theme.current as Record<string, unknown>
const get = (name: string, fallback: string) => {
const item = map[name]
if (typeof item === "string") return item
if (item && typeof item === "object") return item as RGBA
return fallback
}
return (
<smoke_cube
id={`smoke-cube-${value.session_id.slice(0, 8)}`}
width="100%"
height={16}
tint={get("primary", ui.accent)}
spec={get("text", ui.text)}
ambient={get("textMuted", ui.muted)}
key_light={get("success", ui.accent)}
fill_light={get("info", ui.accent)}
/>
)
},
},
})
const reg = (api: TuiApi, input: ReturnType<typeof cfg>) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: input.modal,
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: input.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: () => {
api.route.navigate(route.alert, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
api.route.navigate(route.confirm, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
api.route.navigate(route.prompt, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
api.route.navigate(route.select, current(api, route))
},
},
{
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 themes = {
"workspace-plugin-smoke": mytheme,
}
const tui = async (input: TuiPluginInput, options?: Record<string, unknown>) => {
if (options?.enabled === false) return
const value = cfg(options)
const route = names(value)
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} params={params} />,
},
{
name: route.modal,
render: ({ params }) => <Modal api={input.api} input={value} route={route} params={params} />,
},
{
name: route.alert,
render: ({ params }) => <AlertDialog api={input.api} route={route} params={params} />,
},
{
name: route.confirm,
render: ({ params }) => <ConfirmDialog api={input.api} route={route} params={params} />,
},
{
name: route.prompt,
render: ({ params }) => <PromptDialog api={input.api} route={route} params={params} />,
},
{
name: route.select,
render: ({ params }) => <SelectDialog api={input.api} route={route} params={params} />,
},
])
reg(input.api, value)
input.slots.register(slot(value))
}
export default {
themes,
tui,
}

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": {
@@ -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": {

13
.opencode/tui.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/tui.json",
"theme": "workspace-plugin-smoke",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": true,
"label": "workspace"
}
]
]
}

103
bun.lock
View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.19",
"version": "1.2.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -335,8 +335,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.86",
"@opentui/solid": "0.1.86",
"@opentui/core": "0.0.0-20260304-eee67156",
"@opentui/solid": "0.0.0-20260304-eee67156",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -373,7 +373,6 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -396,7 +395,6 @@
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
@@ -409,17 +407,24 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.0.0-20260304-eee67156",
"@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",
@@ -429,7 +434,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.19",
"version": "1.2.18",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -440,7 +445,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -475,7 +480,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -521,7 +526,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"zod": "catalog:",
},
@@ -532,7 +537,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.19",
"version": "1.2.18",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1412,21 +1417,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@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/core": ["@opentui/core@0.0.0-20260304-eee67156", "", { "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-20260304-eee67156", "@opentui/core-darwin-x64": "0.0.0-20260304-eee67156", "@opentui/core-linux-arm64": "0.0.0-20260304-eee67156", "@opentui/core-linux-x64": "0.0.0-20260304-eee67156", "@opentui/core-win32-arm64": "0.0.0-20260304-eee67156", "@opentui/core-win32-x64": "0.0.0-20260304-eee67156", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-CWBnGrd9jp16xlaekDF85h15yQvBsNHtRSJUlmXrp6cQNq80fBcYJIKZfJtLD7Db4kcoimI068F06B23BPMzEQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.86", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zp7q64+d+Dcx6YrH3mRcnHq8EOBnrfc1RvjgSWLhpXr49hY6LzuhqpfZM57aGErPYlR+ff8QM6e5FUkFnDfyjw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260304-eee67156", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gWIjWq4lLvd+yxTeGQ3NkHz76mRGsEOYCqSsZaj+7hUNlK09dDkp/GXMONH0HZTK8enHTRI7XGHuHMtaLC3fSw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.86", "", { "os": "darwin", "cpu": "x64" }, "sha512-NcxfjCJm1kLnTMVOpAPdRYNi8W8XdAXNa6N7i9khiVFrl2v5KRQfUjbrSOUYVxFJNc3jKFG6rsn3jEApvn92qA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260304-eee67156", "", { "os": "darwin", "cpu": "x64" }, "sha512-vgiS74vRPRfssmjYhkFjgEN7po+Vh99dVm+Zq47N5FnVvkvy60D3Z4sBDZsJtnut2u544E2Po9IgA2jY1+w9Zw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.86", "", { "os": "linux", "cpu": "arm64" }, "sha512-EDHAvqSOr8CXzbDvo1aE5blJ6wu1aSbR2LqoXtoeXHemr2T2W42D2TdIWewG6K+/BuRbzZnqt9wnYFBksLW6lw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260304-eee67156", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/ZfI8i6zJcMrHVIbFvNwTSPgrw1LgVZksQnY2qOsxML0nPGgH+kmCTwFHmdJdMVW3PFZHwUE/D6Dp9s/Lrbkg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.86", "", { "os": "linux", "cpu": "x64" }, "sha512-VBaBkVdQDxYV4WcKjb+jgyMS5PiVHepvfaoKWpz1Bq+J01xXW4XPcXyPGkgR1+2R93KzaugEnLscTW4mWtLHlQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260304-eee67156", "", { "os": "linux", "cpu": "x64" }, "sha512-3I2y0Im6XYNgRGyV3vXrHxTL4I2AK2+c7GPootJM3OXWrKt6Si/KpW7lM7dydtQugVhkG52GZJC4EzqgTuSTjA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.86", "", { "os": "win32", "cpu": "arm64" }, "sha512-xKbT7sEKYKGwUPkoqmLfHjbJU+vwHPDwf/r/mIunL41JXQBB35CSZ3/QgIwpp2kkteu7oE1tdBdg15ogUU4OMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260304-eee67156", "", { "os": "win32", "cpu": "arm64" }, "sha512-8DjoIk+vPIVudaa1+24UiEoO+efYiilCWxGWkU0RAaxp11AyvpfGEFzXxRL6WtV5Js0jGVCwYuY5V0BGgbeSNA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.86", "", { "os": "win32", "cpu": "x64" }, "sha512-HRfgAUlcu71/MrtgfX4Gj7PsDtfXZiuC506Pkn1OnRN1Xomcu10BVRDweUa0/g8ldU9i9kLjMGGnpw6/NjaBFg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260304-eee67156", "", { "os": "win32", "cpu": "x64" }, "sha512-Uawsa5RKILp1K9UhcO5q9ODauZnN1UVljYZzZ7GCT48R18Bwpp1cXvGiFfKt7d3zOfNpPKtvs081T8XYNzqKdQ=="],
"@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=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260304-eee67156", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260304-eee67156", "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-h0qqJ78COKWkWphEPBMTScnz8AIaBkYFuN1lvncwxLwEP0cbxSwZrr/e/TZiG+Q9nBBp8OR/lqFS9BJZCPnJKQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2122,8 +2127,6 @@
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
@@ -3240,7 +3243,7 @@
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
@@ -3744,7 +3747,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=="],
@@ -4590,7 +4593,7 @@
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -5206,8 +5209,6 @@
"app-builder-lib/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
@@ -5394,8 +5395,6 @@
"node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
@@ -5422,6 +5421,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=="],
@@ -5532,12 +5535,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=="],
@@ -5926,8 +5929,6 @@
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"archiver-utils/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -6010,8 +6011,6 @@
"node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
@@ -6026,6 +6025,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=="],
@@ -6450,6 +6465,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

@@ -8,7 +8,6 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -282,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await sleep(300)
await Bun.sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
"x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=",
"aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=",
"aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=",
"x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI="
}
}

View File

@@ -197,7 +197,6 @@ export async function createTestProject() {
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root,
@@ -208,10 +207,7 @@ export async function createTestProject() {
}
export async function cleanupTestProject(directory: string) {
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
}
export function sessionIDFromUrl(url: string) {

View File

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

View File

@@ -185,7 +185,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

@@ -22,7 +22,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
@@ -1937,14 +1937,20 @@ export default function Layout(props: ParentProps) {
fallback={
<>
<div class="shrink-0 py-4 px-3">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
{language.t("command.session.new")}
</Button>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
@@ -1959,9 +1965,15 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="shrink-0 py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider

View File

@@ -720,7 +720,6 @@ export default function Page() {
showAllFiles,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
loadFile: file.load,
})

View File

@@ -11,13 +11,12 @@ describe("createOpenReviewFile", () => {
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
})
})

View File

@@ -24,20 +24,15 @@ export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
batch(() => {
input.showAllFiles()
const maybePromise = input.loadFile(path)
const open = () => {
const tab = input.tabForPath(path)
input.openTab(tab)
input.setActive(tab)
}
if (maybePromise instanceof Promise) maybePromise.then(open)
else open()
const openTab = () => input.openTab(input.tabForPath(path))
if (maybePromise instanceof Promise) maybePromise.then(openTab)
else openTab()
})
}
}

View File

@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
@@ -160,7 +160,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
setState("count", count)
startTransition(() => setState("count", count))
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined

View File

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

View File

@@ -108,26 +108,6 @@ const DOCS_SEGMENT = new Set([
"zh-tw",
])
const DOCS_LOCALE = {
ar: "ar",
da: "da",
de: "de",
en: "en",
es: "es",
fr: "fr",
it: "it",
ja: "ja",
ko: "ko",
nb: "no",
"pt-br": "br",
root: "en",
ru: "ru",
th: "th",
tr: "tr",
"zh-cn": "zh",
"zh-tw": "zht",
} as const satisfies Record<string, Locale>
function suffix(pathname: string) {
const index = pathname.search(/[?#]/)
if (index === -1) {
@@ -150,12 +130,7 @@ export function docs(locale: Locale, pathname: string) {
return `${next.path}${next.suffix}`
}
if (value === "root") {
if (next.path === "/docs/en") return `/docs${next.suffix}`
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
return `${next.path}${next.suffix}`
}
if (value === "root") return `${next.path}${next.suffix}`
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
@@ -179,15 +154,6 @@ export function fromPathname(pathname: string) {
return parseLocale(fix(pathname).split("/")[1])
}
export function fromDocsPathname(pathname: string) {
const next = fix(pathname)
const value = next.split("/")[2]?.toLowerCase()
if (!value) return null
if (!next.startsWith("/docs/")) return null
if (!(value in DOCS_LOCALE)) return null
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
}
export function strip(pathname: string) {
const locale = fromPathname(pathname)
if (!locale) return fix(pathname)
@@ -306,9 +272,6 @@ export function localeFromRequest(request: Request) {
const fromPath = fromPathname(new URL(request.url).pathname)
if (fromPath) return fromPath
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
if (fromDocsPath) return fromDocsPath
return (
localeFromCookieHeader(request.headers.get("cookie")) ??
detectFromAcceptLanguage(request.headers.get("accept-language"))

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
import { docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,9 +17,7 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
return response
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
import { docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,9 +17,7 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
return response
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
import { docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,9 +17,7 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
return response
}
export const GET = handler

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.19",
"version": "1.2.18",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.19",
"version": "1.2.18",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -43,7 +43,6 @@
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
@@ -90,8 +89,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.86",
"@opentui/solid": "0.1.86",
"@opentui/core": "0.0.0-20260304-eee67156",
"@opentui/solid": "0.0.0-20260304-eee67156",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -128,7 +127,6 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "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 "../node_modules/@opentui/solid/scripts/solid-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({ mode: "build" })
const allTargets: {
os: string
@@ -171,7 +172,7 @@ for (const item of targets) {
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
plugins: [plugin],
sourcemap: "external",
compile: {
autoloadBunfig: false,

View File

@@ -31,7 +31,6 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
import { Hash } from "../util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -282,7 +281,7 @@ export namespace ACP {
const output = this.bashOutput(part)
const content: ToolCallContent[] = []
if (output) {
const hash = Hash.fast(output)
const hash = String(Bun.hash(output))
if (part.tool === "bash") {
if (this.bashSnapshots.get(part.callID) === hash) {
await this.connection

View File

@@ -72,12 +72,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

@@ -10,11 +10,24 @@ 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 result = Process.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",
abort: AbortSignal.timeout(4_000),
timeout: 750,
env: {
...process.env,
BUN_BE_BUN: "1",

View File

@@ -13,7 +13,6 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -48,7 +47,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await sleep(10)
await Bun.sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {

View File

@@ -3,7 +3,6 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",
@@ -20,7 +19,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
await sleep(1000)
await Bun.sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},

View File

@@ -27,9 +27,7 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { git } from "@/util/git"
import { $ } from "bun"
type GitHubAuthor = {
login: string
@@ -256,7 +254,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -355,7 +353,7 @@ export const GithubInstallCommand = cmd({
}
retries++
await sleep(1000)
await Bun.sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -494,26 +492,6 @@ export const GithubRunCommand = cmd({
? "pr_review"
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
await gitRun(args)
}
try {
if (useGithubToken) {
@@ -574,7 +552,7 @@ export const GithubRunCommand = cmd({
}
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix)
const head = await gitText(["rev-parse", "HEAD"])
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
@@ -608,7 +586,7 @@ export const GithubRunCommand = cmd({
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
await checkoutLocalBranch(prData)
const head = await gitText(["rev-parse", "HEAD"])
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -626,7 +604,7 @@ export const GithubRunCommand = cmd({
// Fork PR
else {
const forkBranch = await checkoutForkBranch(prData)
const head = await gitText(["rev-parse", "HEAD"])
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -645,7 +623,7 @@ export const GithubRunCommand = cmd({
// Issue
else {
const branch = await checkoutNewBranch("issue")
const head = await gitText(["rev-parse", "HEAD"])
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -679,7 +657,7 @@ export const GithubRunCommand = cmd({
exitCode = 1
console.error(e instanceof Error ? e.message : String(e))
let msg = e
if (e instanceof Process.RunFailedError) {
if (e instanceof $.ShellError) {
msg = e.stderr.toString()
} else if (e instanceof Error) {
msg = e.message
@@ -1070,29 +1048,29 @@ export const GithubRunCommand = cmd({
const config = "http.https://github.com/.extraheader"
// actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully
const ret = await gitStatus(["config", "--local", "--get", config])
const ret = await $`git config --local --get ${config}`.nothrow()
if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim()
await gitRun(["config", "--local", "--unset-all", config])
await $`git config --local --unset-all ${config}`
}
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "${AGENT_USERNAME}"`
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
}
async function restoreGitConfig() {
if (gitConfig === undefined) return
const config = "http.https://github.com/.extraheader"
await gitRun(["config", "--local", config, gitConfig])
await $`git config --local ${config} "${gitConfig}"`
}
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...")
const branch = generateBranchName(type)
await gitRun(["checkout", "-b", branch])
await $`git checkout -b ${branch}`
return branch
}
@@ -1102,8 +1080,8 @@ export const GithubRunCommand = cmd({
const branch = pr.headRefName
const depth = Math.max(pr.commits.totalCount, 20)
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
await gitRun(["checkout", branch])
await $`git fetch origin --depth=${depth} ${branch}`
await $`git checkout ${branch}`
}
async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1113,9 +1091,9 @@ export const GithubRunCommand = cmd({
const localBranch = generateBranchName("pr")
const depth = Math.max(pr.commits.totalCount, 20)
await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
return localBranch
}
@@ -1136,23 +1114,28 @@ export const GithubRunCommand = cmd({
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
console.log("Pushing to new branch...")
if (commit) {
await gitRun(["add", "."])
await $`git add .`
if (isSchedule) {
await commitChanges(summary)
// No co-author for scheduled events - the schedule is operating as the repo
await $`git commit -m "${summary}"`
} else {
await commitChanges(summary, actor)
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
}
await gitRun(["push", "-u", "origin", branch])
await $`git push -u origin ${branch}`
}
async function pushToLocalBranch(summary: string, commit: boolean) {
console.log("Pushing to local branch...")
if (commit) {
await gitRun(["add", "."])
await commitChanges(summary, actor)
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
await gitRun(["push"])
await $`git push`
}
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1161,28 +1144,30 @@ export const GithubRunCommand = cmd({
const remoteBranch = pr.headRefName
if (commit) {
await gitRun(["add", "."])
await commitChanges(summary, actor)
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
await $`git push fork HEAD:${remoteBranch}`
}
async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR).
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true }
}
const ret = await gitStatus(["status", "--porcelain"])
const ret = await $`git status --porcelain`
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return { dirty: true, uncommittedChanges: true, switched: false }
}
const head = await gitText(["rev-parse", "HEAD"])
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
return {
dirty: head !== originalHead,
uncommittedChanges: false,
@@ -1194,11 +1179,11 @@ export const GithubRunCommand = cmd({
// Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) {
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`)
await gitStatus(["fetch", "origin", base, "--depth=1"])
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
await $`git fetch origin ${base} --depth=1`.nothrow()
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0
}
@@ -1387,7 +1372,7 @@ export const GithubRunCommand = cmd({
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
await sleep(delayMs)
await Bun.sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e

View File

@@ -1,8 +1,7 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
import { git } from "@/util/git"
import { $ } from "bun"
export const PrCommand = cmd({
command: "pr <number>",
@@ -28,35 +27,21 @@ export const PrCommand = cmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
// Use gh pr checkout with custom branch name
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
if (result.code !== 0) {
if (result.exitCode !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult = await Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
const prInfoResult =
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
let sessionId: string | undefined
if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text
if (prInfoResult.exitCode === 0) {
const prInfoText = prInfoResult.text()
if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText)
@@ -67,19 +52,15 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
const remotes = (await $`git remote`.nothrow().text()).trim()
if (!remotes.split("\n").includes(remoteName)) {
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
}
// Check for opencode session link in PR body
@@ -90,11 +71,9 @@ export const PrCommand = cmd({
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
const importResult = await Process.text(["opencode", "import", sessionUrl], {
nothrow: true,
})
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
if (importResult.exitCode === 0) {
const importOutput = importResult.text().trim()
// Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {

View File

@@ -6,7 +6,6 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { text as streamText } from "node:stream/consumers"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
@@ -338,7 +337,7 @@ export const RunCommand = cmd({
}
}
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")

View File

@@ -9,7 +9,6 @@ import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -18,7 +17,7 @@ function pagerCmd(): string[] {
}
// user could have less installed via other options
const lessOnPath = which("less")
const lessOnPath = Bun.which("less")
if (lessOnPath) {
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
}
@@ -28,7 +27,7 @@ function pagerCmd(): string[] {
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
const git = which("git")
const git = Bun.which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]

View File

@@ -1,13 +1,25 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { render, 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"
@@ -20,7 +32,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-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"
@@ -28,6 +40,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 } from "./ui/dialog-select"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
@@ -40,6 +55,8 @@ import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import type { TuiApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { TuiPlugin } from "./plugin"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -103,6 +120,25 @@ 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}`)
})
},
},
}
}
export function tui(input: {
url: string
args: Args
@@ -130,73 +166,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)
})
}
@@ -209,12 +229,189 @@ 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 t = useTheme()
const { theme, mode, setMode } = t
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const routes = new Map<string, { key: symbol; render: TuiRouteDefinition["render"] }[]>()
const [rev, setRev] = createSignal(0)
const view = (name: string) => {
rev()
return routes.get(name)?.at(-1)?.render
}
const api: TuiApi<JSX.Element> = {
command: {
register(cb) {
command.register(() => cb())
},
trigger(value) {
command.trigger(value)
},
},
route: {
register(input) {
const key = Symbol()
for (const item of input) {
const list = routes.get(item.name) ?? []
list.push({ key, render: item.render })
routes.set(item.name, list)
}
setRev((x) => x + 1)
return () => {
for (const item of input) {
const list = routes.get(item.name)
if (!list) continue
routes.set(
item.name,
list.filter((x) => x.key !== key),
)
if (!routes.get(item.name)?.length) routes.delete(item.name)
}
setRev((x) => x + 1)
}
},
navigate(name, params) {
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 })
},
get 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,
}
},
},
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) {
const list = props.options.map((item) => ({
...item,
footer: item.footer as JSX.Element | string | undefined,
onSelect: () => item.onSelect?.(),
}))
return (
<DialogSelect
title={props.title}
placeholder={props.placeholder}
options={list}
flat={props.flat}
onMove={
props.onMove
? (item) =>
props.onMove?.({
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
})
: undefined
}
onFilter={props.onFilter}
onSelect={
props.onSelect
? (item) =>
props.onSelect?.({
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
})
: undefined
}
skipFilter={props.skipFilter}
current={props.current}
/>
)
},
toast(input) {
toast.show({
title: input.title,
message: input.message,
variant: input.variant ?? "info",
duration: input.duration,
})
},
},
keybind: {
parse(evt: ParsedKey) {
return keybind.parse(evt)
},
match(key, evt: ParsedKey) {
return keybind.match(key, evt)
},
print(key) {
return keybind.print(key)
},
},
theme: {
get current() {
return theme
},
get selected() {
return t.selected
},
mode() {
return t.mode()
},
get ready() {
return t.ready
},
},
}
TuiPlugin.init({
client: sdk.client,
event: sdk.event,
renderer,
api,
}).catch((error) => {
console.error("Failed to load TUI plugins", error)
})
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
@@ -257,10 +454,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
@@ -277,9 +470,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}`)
}
})
@@ -734,6 +931,13 @@ function App() {
})
})
const plugin = createMemo(() => {
if (route.data.type !== "plugin") return
const render = view(route.data.id)
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
return render({ params: route.data.data })
})
return (
<box
width={dimensions().width}
@@ -757,6 +961,21 @@ function App() {
<Session />
</Match>
</Switch>
{plugin()}
<TuiPlugin.Slot name="app" />
</box>
)
}
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

@@ -80,21 +80,24 @@ 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))
},
}
return result

View File

@@ -13,7 +13,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",
@@ -31,7 +37,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}

View File

@@ -1,6 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
@@ -138,6 +138,44 @@ type ThemeJson = {
}
}
type ThemeRegistry = {
themes: Record<string, ThemeJson>
listeners: Set<(themes: Record<string, ThemeJson>) => void>
}
const registry: ThemeRegistry = {
themes: {},
listeners: new Set(),
}
export function registerThemes(themes: Record<string, unknown>) {
const entries = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => {
const theme = entry[1]
if (!theme || typeof theme !== "object") return false
if (!("theme" in theme)) return false
return true
})
if (entries.length === 0) return
for (const [name, theme] of entries) {
registry.themes[name] = theme
}
const payload = Object.fromEntries(entries)
for (const handler of registry.listeners) {
handler(payload)
}
}
function registeredThemes() {
return registry.themes
}
function onThemes(handler: (themes: Record<string, ThemeJson>) => void) {
registry.listeners.add(handler)
return () => registry.listeners.delete(handler)
}
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
aura,
ayu,
@@ -296,6 +334,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function init() {
resolveSystemTheme()
mergeThemes(registeredThemes())
getCustomThemes()
.then((custom) => {
setStore(
@@ -315,6 +354,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
onMount(init)
onCleanup(
onThemes((themes) => {
mergeThemes(themes)
}),
)
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
}
}),
)
}
function resolveSystemTheme() {
console.log("resolveSystemTheme")

View File

@@ -0,0 +1,183 @@
import {
type TuiPlugin as TuiPluginFn,
type TuiPluginInput,
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 "@opentui/solid/preload"
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 { registerThemes } from "./context/theme"
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">
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 getThemes(value: unknown) {
if (!isRecord(value) || !("themes" in value)) return
if (!isRecord(value.themes)) return
return value.themes
}
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
}
export namespace TuiPlugin {
const log = Log.create({ service: "tui.plugin" })
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) {
if (loaded) return loaded
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 ?? []
let deps: Promise<void> | undefined
const wait = async () => {
if (deps) {
await deps
return
}
deps = TuiConfig.waitForDependencies().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await deps
}
const loadOne = async (item: (typeof plugins)[number], retry = false) => {
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 false
const mod = await import(target).catch((error) => {
log.error("failed to load tui plugin", { path: spec, retry, error })
return
})
if (!mod) return false
for (const [name, entry] of uniqueModuleEntries(mod)) {
if (!entry || typeof entry !== "object") {
log.warn("ignoring non-object tui plugin export", {
path: spec,
name,
type: entry === null ? "null" : typeof entry,
})
continue
}
const theme = getThemes(entry)
if (theme) registerThemes(theme)
const slotPlugin = getTuiSlotPlugin(entry)
if (slotPlugin) input.slots.register(slotPlugin)
const tuiPlugin = getTuiPlugin(entry)
if (!tuiPlugin) continue
await tuiPlugin(input, Config.pluginOptions(item))
}
return true
}
for (const item of plugins) {
const ok = await loadOne(item)
if (ok) continue
const spec = Config.pluginSpecifier(item)
if (!spec.startsWith("file://")) continue
await wait()
await loadOne(item, true)
}
},
}).catch((error) => {
log.error("failed to load tui plugins", { directory: dir, error })
})
}
}

View File

@@ -14,6 +14,7 @@ import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { TuiPlugin } from "../plugin"
// TODO: what is the best way to do this?
let once = false
@@ -56,8 +57,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()}>
@@ -70,8 +71,8 @@ export function Home() {
</Match>
</Switch>
</text>
</box>
</Show>
</Show>
</box>
)
let prompt: PromptRef
@@ -97,7 +98,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}>

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

@@ -11,6 +11,7 @@ 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"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
@@ -90,6 +91,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<TuiPlugin.Slot name="sidebar_top" session_id={props.sessionID} />
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session().title}</b>

View File

@@ -3,7 +3,6 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { text as streamText } from "node:stream/consumers"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
@@ -54,7 +53,7 @@ async function target() {
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
if (!value) return piped
if (!piped) return value
return piped + "\n" + value

View File

@@ -1,12 +1,11 @@
import { $ } from "bun"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
import { which } from "../../../../util/which"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -34,38 +33,23 @@ export namespace Clipboard {
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await fs.rm(tmpfile, { force: true }).catch(() => {})
await $`rm -f "${tmpfile}"`.nothrow().quiet()
}
}
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
@@ -73,15 +57,13 @@ export namespace Clipboard {
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
}
}
@@ -94,16 +76,16 @@ export namespace Clipboard {
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin" && which("osascript")) {
if (os === "darwin" && Bun.which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
@@ -113,7 +95,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (which("xclip")) {
if (Bun.which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
@@ -127,7 +109,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (which("xsel")) {
if (Bun.which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {

View File

@@ -10,7 +10,6 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -76,7 +75,7 @@ const startEventStream = (directory: string) => {
).catch(() => undefined)
if (!events) {
await sleep(250)
await Bun.sleep(250)
continue
}
@@ -85,7 +84,7 @@ const startEventStream = (directory: string) => {
}
if (!signal.aborted) {
await sleep(250)
await Bun.sleep(250)
}
}
})().catch((error) => {

View File

@@ -3,11 +3,11 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { Global } from "../../global"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
interface UninstallArgs {
keepConfig: boolean
@@ -192,13 +192,16 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
nothrow: true,
})
if (result.code !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
if (method === "choco" && text.includes("not running from an elevated command shell")) {
const result =
method === "choco"
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
: await $`${cmd}`.quiet().nothrow()
if (result.exitCode !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
if (
method === "choco" &&
result.stdout.toString("utf8").includes("not running from an elevated command shell")
) {
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
} else {
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)

View File

@@ -25,12 +25,12 @@ export namespace UI {
export function println(...message: string[]) {
print(...message)
process.stderr.write(EOL)
Bun.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
process.stderr.write(message.join(" "))
Bun.stderr.write(message.join(" "))
}
let blank = false
@@ -44,7 +44,7 @@ export namespace UI {
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: "\x1b[90m",
fg: Bun.color("gray", "ansi") ?? "",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}

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"
@@ -32,12 +32,18 @@ import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
import { Control } from "@/control"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
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" })
@@ -309,8 +315,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,
@@ -449,7 +456,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,
@@ -462,6 +469,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
@@ -472,15 +505,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
}
/**
@@ -494,14 +528,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)
@@ -997,7 +1031,7 @@ export namespace Config {
ignore: z.array(z.string()).optional(),
})
.optional(),
plugin: z.string().array().optional(),
plugin: PluginSpec.array().optional(),
snapshot: z.boolean().optional(),
share: z
.enum(["manual", "auto", "disabled"])
@@ -1240,24 +1274,12 @@ export namespace Config {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Filesystem.write(options.path, updated).catch(() => {})
await Bun.write(options.path, updated).catch(() => {})
}
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
@@ -1304,10 +1326,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, {
@@ -1401,5 +1419,3 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write

View File

@@ -70,7 +70,7 @@ export async function migrateTuiConfig(input: MigrateInput) {
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
@@ -104,7 +104,7 @@ async function backupAndStripLegacy(file: string, source: string) {
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Filesystem.write(backup, source)
: await Bun.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
@@ -123,7 +123,7 @@ async function backupAndStripLegacy(file: string, source: string) {
return applyEdits(acc, edits)
}, source)
return Filesystem.write(file, text)
return Bun.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true

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 {
@@ -18,7 +19,11 @@ export namespace TuiConfig {
export type Info = z.output<typeof Info>
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() {
@@ -67,9 +72,23 @@ export namespace TuiConfig {
}
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
result.plugin = Config.deduplicatePlugins(result.plugin ?? [])
const deps: Promise<void>[] = []
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(
(async () => {
const shouldInstall = await Config.needsInstall(dir)
if (!shouldInstall) return
await Config.installDependencies(dir)
})(),
)
}
return {
config: result,
deps,
}
})
@@ -77,6 +96,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,19 +111,19 @@ 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>) }
const copy = { ...raw }
if (!("tui" in copy)) return copy
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
if (!isRecord(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui as Record<string, unknown>
const tui = copy.tui
delete copy.tui
return {
...tui,
@@ -113,6 +137,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

@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { $ } from "bun"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
@@ -10,7 +11,6 @@ import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
export namespace File {
const log = Log.create({ service: "file" })
@@ -418,11 +418,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const changedFiles: Info[] = []
@@ -439,14 +439,11 @@ export namespace File {
}
}
const untrackedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
{
cwd: Instance.directory,
},
)
).text()
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -467,14 +464,11 @@ export namespace File {
}
// Get deleted files
const deletedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
{
cwd: Instance.directory,
},
)
).text()
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -545,14 +539,10 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
).text()
}
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,

View File

@@ -5,10 +5,9 @@ import fs from "fs/promises"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
@@ -127,7 +126,7 @@ export namespace Ripgrep {
)
const state = lazy(async () => {
const system = which("rg")
const system = Bun.which("rg")
if (system) {
const stat = await fs.stat(system).catch(() => undefined)
if (stat?.isFile()) return { filepath: system }
@@ -338,7 +337,7 @@ export namespace Ripgrep {
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.follow) args.push("--follow")
if (input.glob) {
@@ -354,16 +353,14 @@ export namespace Ripgrep {
args.push("--")
args.push(input.pattern)
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
const lines = result.text().trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines

View File

@@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
import { git } from "@/util/git"
const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -88,10 +88,13 @@ export namespace FileWatcher {
}
if (Instance.project.vcs === "git") {
const result = await git(["rev-parse", "--git-dir"], {
cwd: Instance.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
const vcsDir = await $`git rev-parse --git-dir`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => path.resolve(Instance.worktree, x.trim()))
.catch(() => undefined)
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")

View File

@@ -3,7 +3,6 @@ import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
export interface Info {
@@ -19,7 +18,7 @@ export const gofmt: Info = {
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return which("gofmt") !== null
return Bun.which("gofmt") !== null
},
}
@@ -28,7 +27,7 @@ export const mix: Info = {
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return which("mix") !== null
return Bun.which("mix") !== null
},
}
@@ -153,7 +152,7 @@ export const zig: Info = {
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return which("zig") !== null
return Bun.which("zig") !== null
},
}
@@ -172,7 +171,7 @@ export const ktlint: Info = {
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return which("ktlint") !== null
return Bun.which("ktlint") !== null
},
}
@@ -181,7 +180,7 @@ export const ruff: Info = {
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!which("ruff")) return false
if (!Bun.which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
@@ -211,7 +210,7 @@ export const rlang: Info = {
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = which("air")
const airPath = Bun.which("air")
if (airPath == null) return false
try {
@@ -240,7 +239,7 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (which("uv") !== null) {
if (Bun.which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
@@ -254,7 +253,7 @@ export const rubocop: Info = {
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("rubocop") !== null
return Bun.which("rubocop") !== null
},
}
@@ -263,7 +262,7 @@ export const standardrb: Info = {
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("standardrb") !== null
return Bun.which("standardrb") !== null
},
}
@@ -272,7 +271,7 @@ export const htmlbeautifier: Info = {
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return which("htmlbeautifier") !== null
return Bun.which("htmlbeautifier") !== null
},
}
@@ -281,7 +280,7 @@ export const dart: Info = {
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return which("dart") !== null
return Bun.which("dart") !== null
},
}
@@ -290,7 +289,7 @@ export const ocamlformat: Info = {
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!which("ocamlformat")) return false
if (!Bun.which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
},
@@ -301,7 +300,7 @@ export const terraform: Info = {
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return which("terraform") !== null
return Bun.which("terraform") !== null
},
}
@@ -310,7 +309,7 @@ export const latexindent: Info = {
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return which("latexindent") !== null
return Bun.which("latexindent") !== null
},
}
@@ -319,7 +318,7 @@ export const gleam: Info = {
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return which("gleam") !== null
return Bun.which("gleam") !== null
},
}
@@ -328,7 +327,7 @@ export const shfmt: Info = {
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return which("shfmt") !== null
return Bun.which("shfmt") !== null
},
}
@@ -337,7 +336,7 @@ export const nixfmt: Info = {
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return which("nixfmt") !== null
return Bun.which("nixfmt") !== null
},
}
@@ -346,7 +345,7 @@ export const rustfmt: Info = {
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return which("rustfmt") !== null
return Bun.which("rustfmt") !== null
},
}
@@ -373,7 +372,7 @@ export const ormolu: Info = {
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return which("ormolu") !== null
return Bun.which("ormolu") !== null
},
}
@@ -382,7 +381,7 @@ export const cljfmt: Info = {
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return which("cljfmt") !== null
return Bun.which("cljfmt") !== null
},
}
@@ -391,6 +390,6 @@ export const dfmt: Info = {
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return which("dfmt") !== null
return Bun.which("dfmt") !== null
},
}

View File

@@ -1,12 +1,11 @@
import { BusEvent } from "@/bus/bus-event"
import path from "path"
import { $ } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
declare global {
const OPENCODE_VERSION: string
@@ -16,38 +15,6 @@ declare global {
export namespace Installation {
const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
@@ -98,31 +65,31 @@ export namespace Installation {
const checks = [
{
name: "npm" as const,
command: () => text(["npm", "list", "-g", "--depth=0"]),
command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
},
{
name: "yarn" as const,
command: () => text(["yarn", "global", "list"]),
command: () => $`yarn global list`.throws(false).quiet().text(),
},
{
name: "pnpm" as const,
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
},
{
name: "bun" as const,
command: () => text(["bun", "pm", "ls", "-g"]),
command: () => $`bun pm ls -g`.throws(false).quiet().text(),
},
{
name: "brew" as const,
command: () => text(["brew", "list", "--formula", "opencode"]),
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
},
{
name: "scoop" as const,
command: () => text(["scoop", "list", "opencode"]),
command: () => $`scoop list opencode`.throws(false).quiet().text(),
},
{
name: "choco" as const,
command: () => text(["choco", "list", "--limit-output", "opencode"]),
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
},
]
@@ -154,70 +121,61 @@ export namespace Installation {
)
async function getBrewFormula() {
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
}
export async function upgrade(method: Method, target: string) {
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
let cmd
switch (method) {
case "curl":
result = await upgradeCurl(target)
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
break
case "npm":
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
cmd = $`npm install -g opencode-ai@${target}`
break
case "pnpm":
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
cmd = $`pnpm install -g opencode-ai@${target}`
break
case "bun":
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
cmd = $`bun install -g opencode-ai@${target}`
break
case "brew": {
const formula = await getBrewFormula()
const env = {
if (formula.includes("/")) {
cmd =
$`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
{
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
},
)
break
}
cmd = $`brew upgrade ${formula}`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
}
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
})
break
}
case "choco":
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
cmd = $`echo Y | choco upgrade opencode --version=${target}`
break
case "scoop":
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
cmd = $`scoop install opencode@${target}`
break
default:
throw new Error(`Unknown method: ${method}`)
}
if (!result || result.code !== 0) {
const stderr =
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0) {
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
throw new UpgradeFailedError({
stderr: stderr,
})
@@ -228,7 +186,7 @@ export namespace Installation {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
await Process.text([process.execPath, "--version"], { nothrow: true })
await $`${process.execPath} --version`.nothrow().quiet().text()
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
@@ -241,7 +199,7 @@ export namespace Installation {
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await text(["brew", "info", "--json=v2", formula])
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
@@ -257,7 +215,7 @@ export namespace Installation {
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await text(["npm", "config", "get", "registry"])).trim()
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})

View File

@@ -4,6 +4,7 @@ import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -11,7 +12,6 @@ import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -20,8 +20,6 @@ export namespace LSPServer {
.stat(p)
.then(() => true)
.catch(() => false)
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
export interface Handle {
process: ChildProcessWithoutNullStreams
@@ -77,7 +75,7 @@ export namespace LSPServer {
},
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
async spawn(root) {
const deno = which("deno")
const deno = Bun.which("deno")
if (!deno) {
log.info("deno not found, please install deno first")
return
@@ -124,7 +122,7 @@ export namespace LSPServer {
extensions: [".vue"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = which("vue-language-server")
let binary = Bun.which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -206,8 +204,8 @@ export namespace LSPServer {
await fs.rename(extractedPath, finalPath)
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await Process.run([npmCmd, "install"], { cwd: finalPath })
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
await $`${npmCmd} install`.cwd(finalPath).quiet()
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
log.info("installed VS Code ESLint server", { serverPath })
}
@@ -262,7 +260,7 @@ export namespace LSPServer {
let lintBin = await resolveBin(lintTarget)
if (!lintBin) {
const found = which("oxlint")
const found = Bun.which("oxlint")
if (found) lintBin = found
}
@@ -283,7 +281,7 @@ export namespace LSPServer {
let serverBin = await resolveBin(serverTarget)
if (!serverBin) {
const found = which("oxc_language_server")
const found = Bun.which("oxc_language_server")
if (found) serverBin = found
}
if (serverBin) {
@@ -334,7 +332,7 @@ export namespace LSPServer {
let bin: string | undefined
if (await Filesystem.exists(localBin)) bin = localBin
if (!bin) {
const found = which("biome")
const found = Bun.which("biome")
if (found) bin = found
}
@@ -370,11 +368,11 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = which("gopls", {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!which("go")) return
if (!Bun.which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing gopls")
@@ -407,12 +405,12 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop", {
let bin = Bun.which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
const ruby = Bun.which("ruby")
const gem = Bun.which("gem")
if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return
@@ -459,7 +457,7 @@ export namespace LSPServer {
return undefined
}
let binary = which("ty")
let binary = Bun.which("ty")
const initialization: Record<string, string> = {}
@@ -511,7 +509,7 @@ export namespace LSPServer {
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(root) {
let binary = which("pyright-langserver")
let binary = Bun.which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
@@ -565,7 +563,7 @@ export namespace LSPServer {
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(root) {
let binary = which("elixir-ls")
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
@@ -576,7 +574,7 @@ export namespace LSPServer {
)
if (!(await Filesystem.exists(binary))) {
const elixir = which("elixir")
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
@@ -603,11 +601,10 @@ export namespace LSPServer {
recursive: true,
})
const cwd = path.join(Global.Path.bin, "elixir-ls-master")
const env = { MIX_ENV: "prod", ...process.env }
await Process.run(["mix", "deps.get"], { cwd, env })
await Process.run(["mix", "compile"], { cwd, env })
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
@@ -628,12 +625,12 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = which("zls", {
let bin = Bun.which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const zig = which("zig")
const zig = Bun.which("zig")
if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return
@@ -708,7 +705,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
}
await fs.rm(tempPath, { force: true })
@@ -721,7 +718,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await fs.chmod(bin, 0o755).catch(() => {})
await $`chmod +x ${bin}`.quiet().nothrow()
}
log.info(`installed zls`, { bin })
@@ -740,11 +737,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = which("csharp-ls", {
let bin = Bun.which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!which("dotnet")) {
if (!Bun.which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
return
}
@@ -779,11 +776,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = which("fsautocomplete", {
let bin = Bun.which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!which("dotnet")) {
if (!Bun.which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
return
}
@@ -820,7 +817,7 @@ export namespace LSPServer {
async spawn(root) {
// Check if sourcekit-lsp is available in the PATH
// This is installed with the Swift toolchain
const sourcekit = which("sourcekit-lsp")
const sourcekit = Bun.which("sourcekit-lsp")
if (sourcekit) {
return {
process: spawn(sourcekit, {
@@ -831,13 +828,13 @@ export namespace LSPServer {
// If sourcekit-lsp not found, check if xcrun is available
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
if (!which("xcrun")) return
if (!Bun.which("xcrun")) return
const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
if (lspLoc.code !== 0) return
if (lspLoc.exitCode !== 0) return
const bin = lspLoc.text.trim()
const bin = lspLoc.text().trim()
return {
process: spawn(bin, {
@@ -880,7 +877,7 @@ export namespace LSPServer {
},
extensions: [".rs"],
async spawn(root) {
const bin = which("rust-analyzer")
const bin = Bun.which("rust-analyzer")
if (!bin) {
log.info("rust-analyzer not found in path, please install it")
return
@@ -899,7 +896,7 @@ export namespace LSPServer {
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(root) {
const args = ["--background-index", "--clang-tidy"]
const fromPath = which("clangd")
const fromPath = Bun.which("clangd")
if (fromPath) {
return {
process: spawn(fromPath, args, {
@@ -1012,7 +1009,7 @@ export namespace LSPServer {
if (!ok) return
}
if (tar) {
await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
}
await fs.rm(archive, { force: true })
@@ -1023,7 +1020,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await fs.chmod(bin, 0o755).catch(() => {})
await $`chmod +x ${bin}`.quiet().nothrow()
}
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
@@ -1044,7 +1041,7 @@ export namespace LSPServer {
extensions: [".svelte"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = which("svelteserver")
let binary = Bun.which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
@@ -1091,7 +1088,7 @@ export namespace LSPServer {
}
const tsdk = path.dirname(tsserver)
let binary = which("astro-ls")
let binary = Bun.which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
@@ -1135,15 +1132,18 @@ export namespace LSPServer {
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
extensions: [".java"],
async spawn(root) {
const java = which("java")
const java = Bun.which("java")
if (!java) {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return
}
const javaMajorVersion = await run(["java", "-version"]).then((result) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
return !m ? undefined : parseInt(m[1])
})
const javaMajorVersion = await $`java -version`
.quiet()
.nothrow()
.then(({ stderr }) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
return !m ? undefined : parseInt(m[1])
})
if (javaMajorVersion == null || javaMajorVersion < 21) {
log.error("JDTLS requires at least Java 21.")
return
@@ -1160,27 +1160,27 @@ export namespace LSPServer {
const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
if (curlResult.exitCode !== 0) {
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
return
}
await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
log.info("Extracting JDTLS archive")
const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
if (tarResult.code !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
if (tarResult.exitCode !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
return
}
await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed")
}
const jarFileName =
(await fs.readdir(launcherDir).catch(() => []))
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
?.trim() ?? ""
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
.cwd(launcherDir)
.quiet()
.nothrow()
.then(({ stdout }) => stdout.toString().trim())
const launcherJar = path.join(launcherDir, jarFileName)
if (!(await pathExists(launcherJar))) {
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
@@ -1293,15 +1293,7 @@ export namespace LSPServer {
await fs.mkdir(distPath, { recursive: true })
const archivePath = path.join(distPath, "kotlin-ls.zip")
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download Kotlin Language Server", {
status: download.status,
statusText: download.statusText,
})
return
}
await Filesystem.writeStream(archivePath, download.body)
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
const ok = await Archive.extractZip(archivePath, distPath)
.then(() => true)
.catch((error) => {
@@ -1311,7 +1303,7 @@ export namespace LSPServer {
if (!ok) return
await fs.rm(archivePath, { force: true })
if (process.platform !== "win32") {
await fs.chmod(launcherScript, 0o755).catch(() => {})
await $`chmod +x ${launcherScript}`.quiet().nothrow()
}
log.info("Installed Kotlin Language Server", { path: launcherScript })
}
@@ -1332,7 +1324,7 @@ export namespace LSPServer {
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = which("yaml-language-server")
let binary = Bun.which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -1388,7 +1380,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = which("lua-language-server", {
let bin = Bun.which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1475,9 +1467,10 @@ export namespace LSPServer {
})
if (!ok) return
} else {
const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
.then((result) => result.code === 0)
.catch((error: unknown) => {
const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
.quiet()
.then(() => true)
.catch((error) => {
log.error("Failed to extract lua-language-server archive", { error })
return false
})
@@ -1495,15 +1488,11 @@ export namespace LSPServer {
}
if (platform !== "win32") {
const ok = await fs
.chmod(bin, 0o755)
.then(() => true)
.catch((error: unknown) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
})
return false
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
})
})
if (!ok) return
}
@@ -1523,7 +1512,7 @@ export namespace LSPServer {
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
let binary = which("intelephense")
let binary = Bun.which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
@@ -1567,7 +1556,7 @@ export namespace LSPServer {
extensions: [".prisma"],
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
async spawn(root) {
const prisma = which("prisma")
const prisma = Bun.which("prisma")
if (!prisma) {
log.info("prisma not found, please install prisma")
return
@@ -1585,7 +1574,7 @@ export namespace LSPServer {
extensions: [".dart"],
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
async spawn(root) {
const dart = which("dart")
const dart = Bun.which("dart")
if (!dart) {
log.info("dart not found, please install dart first")
return
@@ -1603,7 +1592,7 @@ export namespace LSPServer {
extensions: [".ml", ".mli"],
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
async spawn(root) {
const bin = which("ocamllsp")
const bin = Bun.which("ocamllsp")
if (!bin) {
log.info("ocamllsp not found, please install ocaml-lsp-server")
return
@@ -1620,7 +1609,7 @@ export namespace LSPServer {
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
async spawn(root) {
let binary = which("bash-language-server")
let binary = Bun.which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
@@ -1659,7 +1648,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = which("terraform-ls", {
let bin = Bun.which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1717,7 +1706,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await fs.chmod(bin, 0o755).catch(() => {})
await $`chmod +x ${bin}`.quiet().nothrow()
}
log.info(`installed terraform-ls`, { bin })
@@ -1742,7 +1731,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = which("texlab", {
let bin = Bun.which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1800,7 +1789,7 @@ export namespace LSPServer {
if (!ok) return
}
if (ext === "tar.gz") {
await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
}
await fs.rm(tempPath, { force: true })
@@ -1813,7 +1802,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await fs.chmod(bin, 0o755).catch(() => {})
await $`chmod +x ${bin}`.quiet().nothrow()
}
log.info("installed texlab", { bin })
@@ -1832,7 +1821,7 @@ export namespace LSPServer {
extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory,
async spawn(root) {
let binary = which("docker-langserver")
let binary = Bun.which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
@@ -1871,7 +1860,7 @@ export namespace LSPServer {
extensions: [".gleam"],
root: NearestRoot(["gleam.toml"]),
async spawn(root) {
const gleam = which("gleam")
const gleam = Bun.which("gleam")
if (!gleam) {
log.info("gleam not found, please install gleam first")
return
@@ -1889,9 +1878,9 @@ export namespace LSPServer {
extensions: [".clj", ".cljs", ".cljc", ".edn"],
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
async spawn(root) {
let bin = which("clojure-lsp")
let bin = Bun.which("clojure-lsp")
if (!bin && process.platform === "win32") {
bin = which("clojure-lsp.exe")
bin = Bun.which("clojure-lsp.exe")
}
if (!bin) {
log.info("clojure-lsp not found, please install clojure-lsp first")
@@ -1920,7 +1909,7 @@ export namespace LSPServer {
return Instance.directory
},
async spawn(root) {
const nixd = which("nixd")
const nixd = Bun.which("nixd")
if (!nixd) {
log.info("nixd not found, please install nixd first")
return
@@ -1941,7 +1930,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = which("tinymist", {
let bin = Bun.which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -2005,7 +1994,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
}
await fs.rm(tempPath, { force: true })
@@ -2018,7 +2007,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await fs.chmod(bin, 0o755).catch(() => {})
await $`chmod +x ${bin}`.quiet().nothrow()
}
log.info("installed tinymist", { bin })
@@ -2035,7 +2024,7 @@ export namespace LSPServer {
extensions: [".hs", ".lhs"],
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
async spawn(root) {
const bin = which("haskell-language-server-wrapper")
const bin = Bun.which("haskell-language-server-wrapper")
if (!bin) {
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
return
@@ -2053,7 +2042,7 @@ export namespace LSPServer {
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
async spawn(root) {
const julia = which("julia")
const julia = Bun.which("julia")
if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return

View File

@@ -1,4 +1,3 @@
import { createConnection } from "net"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -161,12 +160,21 @@ export namespace McpOAuthCallback {
export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => {
Bun.connect({
hostname: "127.0.0.1",
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()
resolve(true)
},
error() {
resolve(false)
},
data() {},
close() {},
},
}).catch(() => {
resolve(false)
})
})

View File

@@ -4,7 +4,6 @@ import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises"
const log = Log.create({ service: "plugin.codex" })
@@ -362,7 +361,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.4",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.1-codex",
@@ -604,7 +602,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return { type: "failed" as const }
}
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
}
},
}

View File

@@ -1,7 +1,6 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
import { setTimeout as sleep } from "node:timers/promises"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
@@ -271,7 +270,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}
if (data.error === "authorization_pending") {
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
@@ -287,13 +286,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
newInterval = serverInterval * 1000
}
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},

View File

@@ -4,13 +4,13 @@ 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 {
@@ -53,48 +53,71 @@ 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 ""
async function resolve(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(),
})
if (!plugin) continue
}
return ""
})
if (!target) return
return target
}
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
}
for (const item of plugins) {
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")) continue
log.info("loading plugin", { path: spec })
const path = await resolve(spec)
if (!path) continue
const mod = await import(path).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) continue
// 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 (async () => {
for (const [, entry] of uniqueModuleEntries(mod)) {
const server = getServerPlugin(entry)
if (!server) throw new TypeError("Plugin export is not a function")
hooks.push(await server(input, Config.pluginOptions(item)))
}
})().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 {

View File

@@ -0,0 +1,26 @@
import { BunProc } from "@/bun"
export function parsePluginSpecifier(spec: string) {
const at = spec.lastIndexOf("@")
const pkg = at > 0 ? spec.substring(0, at) : spec
const version = at > 0 ? spec.substring(at + 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

@@ -14,7 +14,6 @@ import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -98,7 +97,7 @@ export namespace Project {
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = which("git")
const gitBinary = Bun.which("git")
// cached id calculation
let id = await Filesystem.readText(path.join(dotgit, "opencode"))

View File

@@ -1,11 +1,11 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { $ } from "bun"
import path from "path"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
const log = Log.create({ service: "vcs" })
@@ -29,13 +29,13 @@ export namespace Vcs {
export type Info = z.infer<typeof Info>
async function currentBranch() {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: Instance.worktree,
})
if (result.exitCode !== 0) return
const text = result.text().trim()
if (!text) return
return text
return $`git rev-parse --abbrev-ref HEAD`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => x.trim())
.catch(() => undefined)
}
const state = Instance.state(

View File

@@ -6,10 +6,9 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "./models"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
@@ -796,7 +795,7 @@ export namespace Provider {
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const sdk = new Map<string, SDK>()
const sdk = new Map<number, SDK>()
log.info("init")
@@ -1086,7 +1085,7 @@ export namespace Provider {
...model.headers,
}
const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const existing = s.sdk.get(key)
if (existing) return existing

View File

@@ -1,10 +1,8 @@
import { Flag } from "@/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@/util/which"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
@@ -24,13 +22,13 @@ export namespace Shell {
try {
process.kill(-pid, "SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
await Bun.sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
await Bun.sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
@@ -41,7 +39,7 @@ export namespace Shell {
function fallback() {
if (process.platform === "win32") {
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = which("git")
const git = Bun.which("git")
if (git) {
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
@@ -51,7 +49,7 @@ export namespace Shell {
return process.env.COMSPEC || "cmd.exe"
}
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
const bash = Bun.which("bash")
if (bash) return bash
return "/bin/sh"
}

View File

@@ -1,6 +1,6 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
@@ -8,17 +8,12 @@ import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
@@ -38,13 +33,13 @@ export namespace Snapshot {
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
if (result.exitCode !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
@@ -59,27 +54,27 @@ export namespace Snapshot {
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await Process.run(["git", "init"], {
env: {
await $`git init`
.env({
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
},
nothrow: true,
})
})
.quiet()
.nothrow()
// Configure git to not convert line endings on Windows
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
log.info("initialized")
}
await add(git)
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
@@ -93,32 +88,19 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
// If git diff fails, return empty patch
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
if (result.exitCode !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.exitCode })
return { hash, files: [] }
}
const files = result.text
const files = result.text()
return {
hash,
files: files
@@ -133,37 +115,20 @@ export namespace Snapshot {
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
@@ -173,37 +138,19 @@ export namespace Snapshot {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
const checkTree =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
@@ -220,36 +167,23 @@ export namespace Snapshot {
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.code !== 0) {
if (result.exitCode !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text.trim()
return result.text().trim()
}
export const FileDiff = z
@@ -270,24 +204,12 @@ export namespace Snapshot {
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
const statuses =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
for (const line of statuses.trim().split("\n")) {
if (!line) continue
@@ -297,57 +219,26 @@ export namespace Snapshot {
status.set(file, kind)
}
for (const line of await Process.lines(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.lines()) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
.quiet()
.nothrow()
.text()
const after = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
.quiet()
.nothrow()
.text()
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({
@@ -369,22 +260,10 @@ export namespace Snapshot {
async function add(git: string) {
await syncExclude(git)
await Process.run(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
.quiet()
.cwd(Instance.directory)
.nothrow()
}
async function syncExclude(git: string) {
@@ -392,19 +271,21 @@ export namespace Snapshot {
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
await Filesystem.write(target, "")
await Bun.write(target, "")
return
}
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text)
const text = await Bun.file(file)
.text()
.catch(() => "")
await Bun.write(target, text)
}
async function excludes() {
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
.quiet()
.cwd(Instance.worktree)
.nothrow()
.text()
if (!file.trim()) return
const exists = await fs
.stat(file.trim())

View File

@@ -5,10 +5,10 @@ import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Lock } from "../util/lock"
import { $ } from "bun"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { Glob } from "../util/glob"
import { git } from "@/util/git"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -49,15 +49,18 @@ export namespace Storage {
}
if (!worktree) continue
if (!(await Filesystem.isDir(worktree))) continue
const result = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
})
const [id] = result
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
if (!id) continue
projectID = id

View File

@@ -7,8 +7,8 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import fs from "fs/promises"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
@@ -116,7 +116,12 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
const resolved = await $`realpath ${arg}`
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
log.info("resolved path", { arg, resolved })
if (resolved) {
const normalized =

View File

@@ -1,5 +1,5 @@
import { $ } from "bun"
import path from "path"
import { Process } from "./process"
export namespace Archive {
export async function extractZip(zipPath: string, destDir: string) {
@@ -8,10 +8,9 @@ export namespace Archive {
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
} else {
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
}

View File

@@ -1,7 +0,0 @@
import { createHash } from "crypto"
export namespace Hash {
export function fast(input: string | Buffer): string {
return createHash("sha1").update(input).digest("hex")
}
}

View File

@@ -25,10 +25,6 @@ export namespace Process {
stderr: Buffer
}
export interface TextResult extends Result {
text: string
}
export class RunFailedError extends Error {
readonly cmd: string[]
readonly code: number
@@ -118,33 +114,13 @@ export namespace Process {
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
stderr,
}))
.catch((err: unknown) => {
if (!opts.nothrow) throw err
return {
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
}
})
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
const out = {
code,
stdout,
stderr,
}
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
}

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

@@ -1,10 +0,0 @@
import whichPkg from "which"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const result = whichPkg.sync(cmd, {
nothrow: true,
path: env?.PATH,
pathExt: env?.PATHEXT,
})
return typeof result === "string" ? result : null
}

View File

@@ -1,3 +1,4 @@
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import z from "zod"
@@ -10,8 +11,6 @@ import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { git } from "../util/git"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
@@ -249,14 +248,14 @@ export namespace Worktree {
}
async function sweep(root: string) {
const first = await git(["clean", "-ffdx"], { cwd: root })
const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root)
if (first.exitCode === 0) return first
const entries = failed(first)
if (!entries.length) return first
await prune(root, entries)
return git(["clean", "-ffdx"], { cwd: root })
return $`git clean -ffdx`.quiet().nothrow().cwd(root)
}
async function canonical(input: string) {
@@ -275,9 +274,7 @@ export namespace Worktree {
if (await exists(directory)) continue
const ref = `refs/heads/${branch}`
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
})
const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
if (branchCheck.exitCode === 0) continue
return Info.parse({ name, branch, directory })
@@ -288,9 +285,9 @@ export namespace Worktree {
async function runStartCommand(directory: string, cmd: string) {
if (process.platform === "win32") {
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
return $`cmd /c ${cmd}`.nothrow().cwd(directory)
}
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
}
type StartKind = "project" | "worktree"
@@ -300,7 +297,7 @@ export namespace Worktree {
if (!text) return true
const ran = await runStartCommand(directory, text)
if (ran.code === 0) return true
if (ran.exitCode === 0) return true
log.error("worktree start command failed", {
kind,
@@ -347,9 +344,10 @@ export namespace Worktree {
}
export async function createFromInfo(info: Info, startCommand?: string) {
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
cwd: Instance.worktree,
})
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
.quiet()
.nothrow()
.cwd(Instance.worktree)
if (created.exitCode !== 0) {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
}
@@ -361,7 +359,7 @@ export namespace Worktree {
return () => {
const start = async () => {
const populated = await git(["reset", "--hard"], { cwd: info.directory })
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
if (populated.exitCode !== 0) {
const message = errorText(populated) || "Failed to populate worktree"
log.error("worktree checkout failed", { directory: info.directory, message })
@@ -476,12 +474,7 @@ export namespace Worktree {
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
})
const stop = async (target: string) => {
if (!(await exists(target))) return
await git(["fsmonitor--daemon", "stop"], { cwd: target })
}
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
@@ -491,18 +484,14 @@ export namespace Worktree {
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
await stop(directory)
await clean(directory)
}
return true
}
await stop(entry.path)
const removed = await git(["worktree", "remove", "--force", entry.path], {
cwd: Instance.worktree,
})
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
if (removed.exitCode !== 0) {
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (next.exitCode !== 0) {
throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
@@ -519,7 +508,7 @@ export namespace Worktree {
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
}
@@ -539,7 +528,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
@@ -572,7 +561,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Worktree not found" })
}
const remoteList = await git(["remote"], { cwd: Instance.worktree })
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
@@ -591,19 +580,18 @@ export namespace Worktree {
: ""
const remoteHead = remote
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
: { exitCode: 1, stdout: undefined, stderr: undefined }
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
cwd: Instance.worktree,
})
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
cwd: Instance.worktree,
})
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
.quiet()
.nothrow()
.cwd(Instance.worktree)
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
@@ -612,7 +600,7 @@ export namespace Worktree {
}
if (remoteBranch) {
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
@@ -624,7 +612,7 @@ export namespace Worktree {
const worktreePath = entry.path
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
if (resetToTarget.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
}
@@ -634,26 +622,22 @@ export namespace Worktree {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
}
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
cwd: worktreePath,
})
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
if (subReset.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
}
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
cwd: worktreePath,
})
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
}
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
}

View File

@@ -0,0 +1,159 @@
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"
mock.module("@opentui/solid/preload", () => ({}))
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("ignores function-only tui exports and loads object exports", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginPath = path.join(dir, "plugin.ts")
const fnMarker = path.join(dir, "function-called.txt")
const objMarker = path.join(dir, "object-called.txt")
const configPath = path.join(dir, "tui.json")
await Bun.write(
pluginPath,
[
"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) => {",
" if (!options?.obj_marker) return",
" await Bun.write(options.obj_marker, 'called')",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
configPath,
JSON.stringify(
{
plugin: [[pathToFileURL(pluginPath).href, { fn_marker: fnMarker, obj_marker: objMarker }]],
},
null,
2,
),
)
return {
configPath,
fnMarker,
objMarker,
}
},
})
process.env.OPENCODE_TUI_CONFIG = tmp.extra.configPath
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const renderer = {
...Object.create(null),
once(this: CliRenderer) {
return this
},
} satisfies CliRenderer
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: () => {},
},
keybind: {
parse: () => ({
name: "",
ctrl: false,
meta: false,
shift: false,
leader: false,
}),
match: () => false,
print: () => "",
},
theme: {
get current() {
return {}
},
get selected() {
return "opencode"
},
mode() {
return "dark" as const
},
get ready() {
return true
},
},
},
})
expect(await fs.readFile(tmp.extra.objMarker, "utf8")).toBe("called")
await expect(fs.readFile(tmp.extra.fnMarker, "utf8")).rejects.toThrow()
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")
} finally {
cwd.mockRestore()
delete process.env.OPENCODE_TUI_CONFIG
}
})

View File

@@ -1682,7 +1682,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

@@ -508,3 +508,57 @@ 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" }]])
},
})
})
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" }],
])
},
})
})

View File

@@ -1,62 +0,0 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const wintest = process.platform === "win32" ? test : test.skip
describe("file fsmonitor", () => {
wintest("status does not start fsmonitor for readonly git checks", async () => {
await using tmp = await tmpdir({ git: true })
const target = path.join(tmp.path, "tracked.txt")
await fs.writeFile(target, "base\n")
await $`git add tracked.txt`.cwd(tmp.path).quiet()
await $`git commit -m init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
await fs.writeFile(target, "next\n")
await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n")
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.status()
},
})
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
wintest("read does not start fsmonitor for git diffs", async () => {
await using tmp = await tmpdir({ git: true })
const target = path.join(tmp.path, "tracked.txt")
await fs.writeFile(target, "base\n")
await $`git add tracked.txt`.cwd(tmp.path).quiet()
await $`git commit -m init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
await fs.writeFile(target, "next\n")
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.read("tracked.txt")
},
})
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
})

View File

@@ -1,26 +0,0 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import { tmpdir } from "./fixture"
describe("tmpdir", () => {
test("disables fsmonitor for git fixtures", async () => {
await using tmp = await tmpdir({ git: true })
const value = (await $`git config core.fsmonitor`.cwd(tmp.path).quiet().text()).trim()
expect(value).toBe("false")
})
test("removes directories on dispose", async () => {
const tmp = await tmpdir({ git: true })
const dir = tmp.path
await tmp[Symbol.asyncDispose]()
const exists = await fs
.stat(dir)
.then(() => true)
.catch(() => false)
expect(exists).toBe(false)
})
})

View File

@@ -9,27 +9,6 @@ function sanitizePath(p: string): string {
return p.replace(/\0/g, "")
}
function exists(dir: string) {
return fs
.stat(dir)
.then(() => true)
.catch(() => false)
}
function clean(dir: string) {
return fs.rm(dir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
})
}
async function stop(dir: string) {
if (!(await exists(dir))) return
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
}
type TmpDirOptions<T> = {
git?: boolean
config?: Partial<Config.Info>
@@ -41,7 +20,6 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
await fs.mkdir(dirpath, { recursive: true })
if (options?.git) {
await $`git init`.cwd(dirpath).quiet()
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
}
if (options?.config) {
@@ -53,16 +31,12 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
}),
)
}
const extra = await options?.init?.(dirpath)
const realpath = sanitizePath(await fs.realpath(dirpath))
const extra = await options?.init?.(realpath)
const result = {
[Symbol.asyncDispose]: async () => {
try {
await options?.dispose?.(realpath)
} finally {
if (options?.git) await stop(realpath).catch(() => undefined)
await clean(realpath).catch(() => undefined)
}
await options?.dispose?.(dirpath)
// await fs.rm(dirpath, { recursive: true, force: true })
},
path: realpath,
extra: extra as T,

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

@@ -3,7 +3,6 @@
import os from "os"
import path from "path"
import fs from "fs/promises"
import { setTimeout as sleep } from "node:timers/promises"
import { afterAll } from "bun:test"
// Set XDG env vars FIRST, before any src/ imports
@@ -16,7 +15,7 @@ afterAll(async () => {
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
const rm = async (left: number): Promise<void> => {
Bun.gc(true)
await sleep(100)
await Bun.sleep(100)
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
if (!busy(error)) throw error
if (left <= 1) throw error

View File

@@ -7,8 +7,6 @@ import { Worktree } from "../../src/worktree"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
const wintest = process.platform === "win32" ? test : test.skip
describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => {
await using tmp = await tmpdir({ git: true })
@@ -64,33 +62,4 @@ describe("Worktree.remove", () => {
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
wintest("stops fsmonitor before removing a worktree", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-fsmonitor-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
await $`git config core.fsmonitor true`.cwd(dir).quiet()
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
await Bun.write(path.join(dir, "tracked.txt"), "next\n")
await $`git diff`.cwd(dir).quiet()
const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()
expect(before.exitCode).toBe(0)
const ok = await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
})

View File

@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
@@ -44,7 +43,7 @@ describe("pty", () => {
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await sleep(100)
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -89,7 +88,7 @@ describe("pty", () => {
}
Pty.write(a.id, "AAA\n")
await sleep(100)
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -129,7 +128,7 @@ describe("pty", () => {
ctx.connId = 2
Pty.write(a.id, "AAA\n")
await sleep(100)
await Bun.sleep(100)
expect(out.join("")).toContain("AAA")
} finally {

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -136,7 +135,7 @@ describe("session.message-v2.fromError", () => {
new ReadableStream({
async pull(controller) {
controller.enqueue("Hello,")
await sleep(10000)
await Bun.sleep(10000)
controller.enqueue(" World!")
controller.close()
},

View File

@@ -1,82 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { which } from "../../src/util/which"
import { tmpdir } from "../fixture/fixture"
async function cmd(dir: string, name: string, exec = true) {
const ext = process.platform === "win32" ? ".cmd" : ""
const file = path.join(dir, name + ext)
const body = process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"
await fs.writeFile(file, body)
if (process.platform !== "win32") {
await fs.chmod(file, exec ? 0o755 : 0o644)
}
return file
}
function env(PATH: string): NodeJS.ProcessEnv {
return {
PATH,
PATHEXT: process.env["PATHEXT"],
}
}
function same(a: string | null, b: string) {
if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase())
return
}
expect(a).toBe(b)
}
describe("util.which", () => {
test("returns null when command is missing", () => {
expect(which("opencode-missing-command-for-test")).toBeNull()
})
test("finds a command from PATH override", async () => {
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = await cmd(bin, "tool")
same(which("tool", env(bin)), file)
})
test("uses first PATH match", async () => {
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.mkdir(a)
await fs.mkdir(b)
const first = await cmd(a, "dupe")
await cmd(b, "dupe")
same(which("dupe", env([a, b].join(path.delimiter))), first)
})
test("returns null for non-executable file on unix", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
await cmd(bin, "noexec", false)
expect(which("noexec", env(bin))).toBeNull()
})
test("uses PATHEXT on windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = path.join(bin, "pathext.CMD")
await fs.writeFile(file, "@echo off\r\n")
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.19",
"version": "1.2.18",
"type": "module",
"license": "MIT",
"scripts": {
@@ -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-20260304-eee67156",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",

View File

@@ -9,9 +9,8 @@ import type {
Message,
Part,
Auth,
Config,
Config as SDKConfig,
} from "@opencode-ai/sdk"
import type { BunShell } from "./shell"
import { type ToolDefinition } from "./tool"
@@ -32,7 +31,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>
export type AuthHook = {
provider: string

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

@@ -0,0 +1,204 @@
import type { createOpencodeClient as createOpencodeClientV2, Event as TuiEvent } from "@opencode-ai/sdk/v2"
import type { CliRenderer, ParsedKey, Plugin as CorePlugin } from "@opentui/core"
import type { Plugin as ServerPlugin, PluginOptions } from "./index"
export type { CliRenderer, SlotMode } from "@opentui/core"
type HexColor = `#${string}`
type RefName = string
type Variant = {
dark: HexColor | RefName | number
light: HexColor | RefName | number
}
type ThemeColorValue = HexColor | RefName | number | Variant
export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Record<string, ThemeColorValue> & {
selectedListItemText?: ThemeColorValue
backgroundMenu?: ThemeColorValue
thinkingOpacity?: number
}
}
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 TuiDialogProps<Node = unknown> = {
size?: "medium" | "large"
onClose: () => void
children?: Node
}
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
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
}
keybind: {
parse: (evt: ParsedKey) => TuiKeybind
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
theme: TuiTheme
}
export type TuiSlotMap = {
app: {}
home_logo: {}
sidebar_top: {
session_id: string
}
}
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 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,
) => Promise<void>
export type TuiPluginModule<Renderer = CliRenderer, Node = unknown> = {
server?: ServerPlugin
tui?: TuiPlugin<Renderer, Node>
slots?: TuiSlotPlugin
themes?: Record<string, ThemeJson>
}

View File

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

View File

@@ -1338,7 +1338,15 @@ export type Config = {
watcher?: {
ignore?: Array<string>
}
plugin?: Array<string>
plugin?: Array<
| string
| [
string,
{
[key: string]: unknown
},
]
>
snapshot?: boolean
/**
* Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.19",
"version": "1.2.18",
"type": "module",
"license": "MIT",
"exports": {

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