mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-06 22:54:04 +00:00
Compare commits
40 Commits
chore/remo
...
actual-tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60216c92b | ||
|
|
bcb355d6aa | ||
|
|
c922f54051 | ||
|
|
701f62fc41 | ||
|
|
4c3eb84c51 | ||
|
|
b1df662ac5 | ||
|
|
7ed6a55bb5 | ||
|
|
c5e45010f1 | ||
|
|
881a8c7e6f | ||
|
|
a5e7c7396f | ||
|
|
34d557e806 | ||
|
|
36d5c72eb1 | ||
|
|
d5047bdd4c | ||
|
|
942e21a0d1 | ||
|
|
41e103ce65 | ||
|
|
3889473f41 | ||
|
|
a1ce12ea4f | ||
|
|
c5d8ea11f8 | ||
|
|
5141ad7b48 | ||
|
|
f37c4539fe | ||
|
|
08086eba9d | ||
|
|
4e39cd74ab | ||
|
|
a60bb23cd1 | ||
|
|
347be15e40 | ||
|
|
3178650d11 | ||
|
|
38eaa69ee0 | ||
|
|
441ca97c23 | ||
|
|
f871b0ebe4 | ||
|
|
447298f930 | ||
|
|
a145180cba | ||
|
|
c96351c3fe | ||
|
|
cc9f69e2e2 | ||
|
|
056bec4c91 | ||
|
|
1ab43da819 | ||
|
|
895529d000 | ||
|
|
4389ebd930 | ||
|
|
e0a70cff8f | ||
|
|
dc25912308 | ||
|
|
f580643e80 | ||
|
|
d48d546d97 |
775
.opencode/plugins/tui-smoke.tsx
Normal file
775
.opencode/plugins/tui-smoke.tsx
Normal 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,
|
||||
}
|
||||
@@ -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
13
.opencode/tui.json
Normal 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
103
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -720,7 +720,6 @@ export default function Page() {
|
||||
showAllFiles,
|
||||
tabForPath: file.tab,
|
||||
openTab: tabs().open,
|
||||
setActive: tabs().setActive,
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
183
packages/opencode/src/cli/cmd/tui/plugin.ts
Normal file
183
packages/opencode/src/cli/cmd/tui/plugin.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(" ")}`)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
26
packages/opencode/src/plugin/shared.ts
Normal file
26
packages/opencode/src/plugin/shared.ts
Normal 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
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/opencode/src/util/record.ts
Normal file
3
packages/opencode/src/util/record.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
159
packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal file
159
packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" }],
|
||||
])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
306
packages/opencode/test/plugin/loader-shared.test.ts
Normal file
306
packages/opencode/test/plugin/loader-shared.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
204
packages/plugin/src/tui.ts
Normal 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>
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user