mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-03 07:11:31 +08:00
Compare commits
93 Commits
v1.2.23
...
actual-tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89aca018f4 | ||
|
|
c22e979b1b | ||
|
|
3f2e1e446a | ||
|
|
ff2800e0e0 | ||
|
|
f727f17c22 | ||
|
|
8e1392cad5 | ||
|
|
731d840afd | ||
|
|
acf47e6631 | ||
|
|
3645fed416 | ||
|
|
5c95616579 | ||
|
|
3341dba46e | ||
|
|
df44d87bf4 | ||
|
|
91f4b0791a | ||
|
|
e40d929554 | ||
|
|
649b547d20 | ||
|
|
7349fe7841 | ||
|
|
cf620dc96c | ||
|
|
ea5423c2ef | ||
|
|
f5441d82a0 | ||
|
|
eef971897f | ||
|
|
6d183f5739 | ||
|
|
e8959babcc | ||
|
|
f9385bcc63 | ||
|
|
29aab3223c | ||
|
|
3f9603e7a6 | ||
|
|
f98ad6e078 | ||
|
|
6e21cf05f7 | ||
|
|
372eba31e8 | ||
|
|
91b7d5d4ec | ||
|
|
eb64f7c1d4 | ||
|
|
eeee6cf3ae | ||
|
|
d755644f92 | ||
|
|
d5b7498455 | ||
|
|
d974e5345c | ||
|
|
cebc7f0da9 | ||
|
|
2bdb6e6a21 | ||
|
|
9319c810c9 | ||
|
|
2698e74972 | ||
|
|
8a7d0875d8 | ||
|
|
66ac0b3394 | ||
|
|
836e0dbc55 | ||
|
|
3ac74b51f5 | ||
|
|
38f38973b0 | ||
|
|
a7400a77ea | ||
|
|
3cc33e39e7 | ||
|
|
8743dddde6 | ||
|
|
d3db93a0ff | ||
|
|
71b960f2d1 | ||
|
|
72c4deb90c | ||
|
|
e8ba71373c | ||
|
|
11c76c6d9a | ||
|
|
557a92a1f3 | ||
|
|
f6f2ca78ff | ||
|
|
ed4513191d | ||
|
|
5a5405630b | ||
|
|
aab237d89a | ||
|
|
71132093f4 | ||
|
|
796eb3440e | ||
|
|
27671cb324 | ||
|
|
9561e3d99a | ||
|
|
9c3fb456af | ||
|
|
932cac501f | ||
|
|
3b38e8dc24 | ||
|
|
7e1d396427 | ||
|
|
8b45247d24 | ||
|
|
b99e3efad2 | ||
|
|
89d6f60d25 | ||
|
|
ee18c9976e | ||
|
|
794532928f | ||
|
|
7b773c65ec | ||
|
|
e53aa79dc6 | ||
|
|
d9a97249c0 | ||
|
|
86cef16940 | ||
|
|
ce38997c76 | ||
|
|
7e10c728d4 | ||
|
|
3627c67cf2 | ||
|
|
2518fd81f6 | ||
|
|
39ef7fc90e | ||
|
|
37ae0a4051 | ||
|
|
b312928e9f | ||
|
|
2f2856e20a | ||
|
|
831eb6881b | ||
|
|
f20ee2fad2 | ||
|
|
8b9710e56c | ||
|
|
c6262f9d40 | ||
|
|
b749fa90f2 | ||
|
|
8a51cbd253 | ||
|
|
399b8f0701 | ||
|
|
3742e42fdf | ||
|
|
0388ec6862 | ||
|
|
366b8a8034 | ||
|
|
ef9bc4ec9e | ||
|
|
5838b58913 |
38
.github/workflows/storybook.yml
vendored
Normal file
38
.github/workflows/storybook.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- ".github/workflows/storybook.yml"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- ".github/workflows/storybook.yml"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: storybook build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun --cwd packages/storybook build
|
||||
@@ -5,6 +5,11 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"permission": {
|
||||
"edit": {
|
||||
"packages/opencode/migration/*": "deny",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
|
||||
@@ -48,11 +48,11 @@
|
||||
"light": "nord10"
|
||||
},
|
||||
"text": {
|
||||
"dark": "nord4",
|
||||
"dark": "nord6",
|
||||
"light": "nord0"
|
||||
},
|
||||
"textMuted": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord1"
|
||||
},
|
||||
"background": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"light": "nord5"
|
||||
},
|
||||
"backgroundElement": {
|
||||
"dark": "nord1",
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
},
|
||||
"border": {
|
||||
@@ -88,11 +88,11 @@
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffContext": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHunkHeader": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHighlightAdded": {
|
||||
@@ -104,12 +104,12 @@
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffAddedBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#36413C",
|
||||
"light": "#E6EBE7"
|
||||
},
|
||||
"diffRemovedBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#43393D",
|
||||
"light": "#ECE6E8"
|
||||
},
|
||||
"diffContextBg": {
|
||||
"dark": "nord1",
|
||||
@@ -120,12 +120,12 @@
|
||||
"light": "nord4"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#303A35",
|
||||
"light": "#DDE4DF"
|
||||
},
|
||||
"diffRemovedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
"dark": "#3C3336",
|
||||
"light": "#E4DDE0"
|
||||
},
|
||||
"markdownText": {
|
||||
"dark": "nord4",
|
||||
@@ -148,7 +148,7 @@
|
||||
"light": "nord14"
|
||||
},
|
||||
"markdownBlockQuote": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownEmph": {
|
||||
@@ -160,7 +160,7 @@
|
||||
"light": "nord13"
|
||||
},
|
||||
"markdownHorizontalRule": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownListItem": {
|
||||
@@ -184,7 +184,7 @@
|
||||
"light": "nord0"
|
||||
},
|
||||
"syntaxComment": {
|
||||
"dark": "nord3",
|
||||
"dark": "#8B95A7",
|
||||
"light": "nord3"
|
||||
},
|
||||
"syntaxKeyword": {
|
||||
912
.opencode/plugins/tui-smoke.tsx
Normal file
912
.opencode/plugins/tui-smoke.tsx
Normal file
@@ -0,0 +1,912 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
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, TuiKeybindSet, TuiPluginInit, TuiPluginInput } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tabs = ["overview", "counter", "help"]
|
||||
const bind = {
|
||||
modal: "ctrl+shift+m",
|
||||
screen: "ctrl+shift+o",
|
||||
home: "escape,ctrl+h",
|
||||
left: "left,h",
|
||||
right: "right,l",
|
||||
up: "up,k",
|
||||
down: "down,j",
|
||||
alert: "a",
|
||||
confirm: "c",
|
||||
prompt: "p",
|
||||
select: "s",
|
||||
modal_accept: "enter,return",
|
||||
modal_close: "escape",
|
||||
dialog_close: "escape",
|
||||
local: "x",
|
||||
local_push: "enter,return",
|
||||
local_close: "q,backspace",
|
||||
host: "z",
|
||||
}
|
||||
|
||||
const pick = (value: unknown, fallback: string) => {
|
||||
if (typeof value !== "string") return fallback
|
||||
if (!value.trim()) return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const num = (value: unknown, fallback: number) => {
|
||||
if (typeof value !== "number") return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const rec = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
type Cfg = {
|
||||
label: string
|
||||
route: string
|
||||
vignette: number
|
||||
keybinds: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
type Route = {
|
||||
modal: string
|
||||
screen: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
tab: number
|
||||
count: number
|
||||
source: string
|
||||
note: string
|
||||
selected: string
|
||||
local: number
|
||||
}
|
||||
|
||||
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||
return {
|
||||
label: pick(options?.label, "smoke"),
|
||||
route: pick(options?.route, "workspace-smoke"),
|
||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||
keybinds: rec(options?.keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
const names = (input: Cfg) => {
|
||||
return {
|
||||
modal: `${input.route}.modal`,
|
||||
screen: `${input.route}.screen`,
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = TuiKeybindSet
|
||||
const ui = {
|
||||
panel: "#1d1d1d",
|
||||
border: "#4a4a4a",
|
||||
text: "#f0f0f0",
|
||||
muted: "#a5a5a5",
|
||||
accent: "#5f87ff",
|
||||
}
|
||||
|
||||
type Color = RGBA | string
|
||||
|
||||
const 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 = {
|
||||
panel: Color
|
||||
border: Color
|
||||
text: Color
|
||||
muted: Color
|
||||
accent: Color
|
||||
selected: Color
|
||||
}
|
||||
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 : ""
|
||||
const local = typeof params?.local === "number" ? params.local : 0
|
||||
return {
|
||||
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
|
||||
count,
|
||||
source,
|
||||
note,
|
||||
selected,
|
||||
local: Math.max(0, local),
|
||||
}
|
||||
}
|
||||
|
||||
const current = (api: TuiApi, route: Route) => {
|
||||
const value = api.route.current
|
||||
const ok = Object.values(route).includes(value.name)
|
||||
if (!ok) return parse(undefined)
|
||||
if (!("params" in value)) return parse(undefined)
|
||||
return parse(value.params)
|
||||
}
|
||||
|
||||
const opts = [
|
||||
{
|
||||
title: "Overview",
|
||||
value: 0,
|
||||
description: "Switch to overview tab",
|
||||
},
|
||||
{
|
||||
title: "Counter",
|
||||
value: 1,
|
||||
description: "Switch to counter tab",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: 2,
|
||||
description: "Switch to help tab",
|
||||
},
|
||||
]
|
||||
|
||||
const host = (api: TuiApi, input: Cfg, skin: Skin) => {
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<text fg={skin.text}>
|
||||
<b>{input.label} host overlay</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
|
||||
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
|
||||
</box>
|
||||
</box>
|
||||
))
|
||||
}
|
||||
|
||||
const warn = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogAlert = api.ui.DialogAlert
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogAlert
|
||||
title="Smoke alert"
|
||||
message="Testing built-in alert dialog"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const check = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogConfirm = api.ui.DialogConfirm
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogConfirm
|
||||
title="Smoke confirm"
|
||||
message="Apply +1 to counter?"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
|
||||
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const entry = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogPrompt = api.ui.DialogPrompt
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Smoke prompt"
|
||||
value={value.note}
|
||||
onConfirm={(note) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
|
||||
}}
|
||||
onCancel={() => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const picker = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogSelect = api.ui.DialogSelect
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Smoke select"
|
||||
options={opts}
|
||||
current={value.tab}
|
||||
onSelect={(item) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, {
|
||||
...value,
|
||||
tab: typeof item.value === "number" ? item.value : value.tab,
|
||||
selected: item.title,
|
||||
source: "select",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const Screen = (props: {
|
||||
api: TuiApi
|
||||
input: Cfg
|
||||
route: Route
|
||||
keys: Keys
|
||||
meta: TuiPluginInit
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const dim = useTerminalDimensions()
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
const set = (local: number, base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
|
||||
}
|
||||
const push = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
set(next.local + 1, next)
|
||||
}
|
||||
const open = () => {
|
||||
const next = current(props.api, props.route)
|
||||
if (next.local > 0) return
|
||||
set(1, next)
|
||||
}
|
||||
const pop = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
const local = Math.max(0, next.local - 1)
|
||||
set(local, next)
|
||||
}
|
||||
const show = () => {
|
||||
setTimeout(() => {
|
||||
open()
|
||||
}, 0)
|
||||
}
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.screen) return
|
||||
const next = current(props.api, props.route)
|
||||
if (props.api.ui.dialog.open) {
|
||||
if (props.keys.match("dialog_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.ui.dialog.clear()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (next.local > 0) {
|
||||
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
pop(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local_push", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("home", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("left", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("right", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("up", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("down", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.modal, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
open()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("host", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
host(props.api, props.input, skin)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("alert", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
warn(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("confirm", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
check(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("prompt", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
entry(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("select", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
picker(props.api, props.route, next)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} screen</b>
|
||||
<span style={{ fg: skin.muted }}> plugin route</span>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||
{tabs.map((item, i) => {
|
||||
const on = value.tab === i
|
||||
return (
|
||||
<Btn
|
||||
txt={item}
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
|
||||
skin={skin}
|
||||
on={on}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexGrow={1}
|
||||
>
|
||||
{value.tab === 0 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Route: {props.route.screen}</text>
|
||||
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
|
||||
<text fg={skin.muted}>
|
||||
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
|
||||
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.entry.load_count}
|
||||
</text>
|
||||
<text fg={skin.muted}>plugin source: {props.meta.entry.source}</text>
|
||||
<text fg={skin.muted}>source: {value.source}</text>
|
||||
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
|
||||
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
|
||||
<text fg={skin.muted}>local stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 1 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Counter: {value.count}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("up")} / {props.keys.print("down")} change value
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 2 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
|
||||
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
|
||||
close
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingTop={1}>
|
||||
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
|
||||
<Btn txt="local overlay" run={show} skin={skin} />
|
||||
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
|
||||
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
visible={value.local > 0}
|
||||
width={dim().width}
|
||||
height={dim().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dim().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
|
||||
onMouseUp={() => {
|
||||
pop()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
onMouseUp={(evt) => {
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
width={60}
|
||||
maxWidth={dim().width - 2}
|
||||
backgroundColor={skin.panel}
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} local overlay</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="push" run={push} skin={skin} on />
|
||||
<Btn txt="pop" run={pop} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const Modal = (props: { api: TuiApi; input: Cfg; route: Route; keys: Keys; params?: Record<string, unknown> }) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.modal) return
|
||||
|
||||
if (props.keys.match("modal_accept", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" backgroundColor={skin.panel}>
|
||||
<Dialog onClose={() => props.api.route.navigate("home")}>
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} modal</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
|
||||
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn
|
||||
txt="open screen"
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
|
||||
skin={skin}
|
||||
on
|
||||
/>
|
||||
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</Dialog>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const slot = (input: Cfg) => ({
|
||||
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: Cfg, keys: Keys) => {
|
||||
const route = names(input)
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: `${input.label} modal`,
|
||||
value: "plugin.smoke.modal",
|
||||
keybind: keys.get("modal"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.modal, { source: "command" })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} screen`,
|
||||
value: "plugin.smoke.screen",
|
||||
keybind: keys.get("screen"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-screen",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} alert dialog`,
|
||||
value: "plugin.smoke.alert",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-alert",
|
||||
},
|
||||
onSelect: () => {
|
||||
warn(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} confirm dialog`,
|
||||
value: "plugin.smoke.confirm",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-confirm",
|
||||
},
|
||||
onSelect: () => {
|
||||
check(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} prompt dialog`,
|
||||
value: "plugin.smoke.prompt",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-prompt",
|
||||
},
|
||||
onSelect: () => {
|
||||
entry(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} select dialog`,
|
||||
value: "plugin.smoke.select",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-select",
|
||||
},
|
||||
onSelect: () => {
|
||||
picker(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} host overlay`,
|
||||
value: "plugin.smoke.host",
|
||||
keybind: keys.get("host"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-host",
|
||||
},
|
||||
onSelect: () => {
|
||||
host(api, input, tone(api))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} go home`,
|
||||
value: "plugin.smoke.home",
|
||||
category: "Plugin",
|
||||
enabled: api.route.current.name !== "home",
|
||||
onSelect: () => {
|
||||
api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} toast`,
|
||||
value: "plugin.smoke.toast",
|
||||
category: "Plugin",
|
||||
onSelect: () => {
|
||||
api.ui.toast({
|
||||
variant: "info",
|
||||
title: "Smoke",
|
||||
message: "Plugin toast works",
|
||||
duration: 2000,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const tui = async (input: TuiPluginInput, options: Record<string, unknown> | null, meta: TuiPluginInit) => {
|
||||
if (options?.enabled === false) return
|
||||
|
||||
await input.api.theme.install("./smoke-theme.json")
|
||||
input.api.theme.set("smoke-theme")
|
||||
|
||||
const value = cfg(options ?? undefined)
|
||||
const route = names(value)
|
||||
const keys = input.api.keybind.create(bind, value.keybinds)
|
||||
const fx = new VignetteEffect(value.vignette)
|
||||
input.renderer.addPostProcessFn(fx.apply.bind(fx))
|
||||
|
||||
input.api.route.register([
|
||||
{
|
||||
name: route.screen,
|
||||
render: ({ params }) => (
|
||||
<Screen api={input.api} input={value} route={route} keys={keys} meta={meta} params={params} />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: route.modal,
|
||||
render: ({ params }) => <Modal api={input.api} input={value} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
])
|
||||
|
||||
reg(input.api, value, keys)
|
||||
input.slots.register(slot(value))
|
||||
}
|
||||
|
||||
export default {
|
||||
tui,
|
||||
}
|
||||
1
.opencode/themes/.gitignore
vendored
Normal file
1
.opencode/themes/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
smoke-theme.json
|
||||
19
.opencode/tui.json
Normal file
19
.opencode/tui.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "smoke-theme",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": true,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"modal": "ctrl+alt+m",
|
||||
"screen": "ctrl+alt+o",
|
||||
"home": "escape,ctrl+shift+h",
|
||||
"dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
||||
"x86_64-linux": "sha256-+SMpaj0jeIHjlddAu6QIwojmWFVIiA8/G32hiQMjcOk=",
|
||||
"aarch64-linux": "sha256-uo63IF6OCMab+O3ngn1sVxqIGJMm04HXuDgIRmXNTNk=",
|
||||
"aarch64-darwin": "sha256-yB2tWm6AsX6UifnDqe7VldhN5zTQkDoqZ87AGQYjxT4=",
|
||||
"x86_64-darwin": "sha256-nNhtqMSG4/y+uxjj14Jc5QQ7X6hQli9ni4v56XAvaAU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
432
packages/app/src/components/debug-bar.tsx
Normal file
432
packages/app/src/components/debug-bar.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
type Mem = Performance & {
|
||||
memory?: {
|
||||
usedJSHeapSize: number
|
||||
jsHeapSizeLimit: number
|
||||
}
|
||||
}
|
||||
|
||||
type Evt = PerformanceEntry & {
|
||||
interactionId?: number
|
||||
processingStart?: number
|
||||
}
|
||||
|
||||
type Shift = PerformanceEntry & {
|
||||
hadRecentInput: boolean
|
||||
value: number
|
||||
}
|
||||
|
||||
type Obs = PerformanceObserverInit & {
|
||||
durationThreshold?: number
|
||||
}
|
||||
|
||||
const span = 5000
|
||||
|
||||
const ms = (n?: number, d = 0) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
return `${n.toFixed(d)}ms`
|
||||
}
|
||||
|
||||
const time = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
return `${Math.round(n)}`
|
||||
}
|
||||
|
||||
const mb = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
const v = n / 1024 / 1024
|
||||
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||
}
|
||||
|
||||
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
if (n === undefined || Number.isNaN(n)) return false
|
||||
return low ? n < limit : n > limit
|
||||
}
|
||||
|
||||
const session = (path: string) => path.includes("/session")
|
||||
|
||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string }) {
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="left">
|
||||
<div class="flex w-full flex-col items-center px-0.5 py-1 text-center">
|
||||
<div class="text-[7px] font-black uppercase tracking-[0.04em] opacity-70 leading-none">{props.label}</div>
|
||||
<div
|
||||
classList={{
|
||||
"text-[9px] font-semibold leading-none tabular-nums": true,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
const [state, setState] = createStore({
|
||||
cls: undefined as number | undefined,
|
||||
delay: undefined as number | undefined,
|
||||
fps: undefined as number | undefined,
|
||||
gap: undefined as number | undefined,
|
||||
heap: {
|
||||
limit: undefined as number | undefined,
|
||||
used: undefined as number | undefined,
|
||||
},
|
||||
inp: undefined as number | undefined,
|
||||
jank: undefined as number | undefined,
|
||||
long: {
|
||||
block: undefined as number | undefined,
|
||||
count: undefined as number | undefined,
|
||||
max: undefined as number | undefined,
|
||||
},
|
||||
nav: {
|
||||
dur: undefined as number | undefined,
|
||||
pending: false,
|
||||
},
|
||||
})
|
||||
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
const value = heap()
|
||||
if (value === undefined) return "n/a"
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
|
||||
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
|
||||
|
||||
let prev = ""
|
||||
let start = 0
|
||||
let init = false
|
||||
let one = 0
|
||||
let two = 0
|
||||
|
||||
createEffect(() => {
|
||||
const busy = routing()
|
||||
const next = `${location.pathname}${location.search}`
|
||||
|
||||
if (!init) {
|
||||
init = true
|
||||
prev = next
|
||||
return
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
one = 0
|
||||
two = 0
|
||||
if (start !== 0) return
|
||||
start = performance.now()
|
||||
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (start === 0) {
|
||||
prev = next
|
||||
return
|
||||
}
|
||||
|
||||
const at = start
|
||||
const from = prev
|
||||
start = 0
|
||||
prev = next
|
||||
|
||||
if (!(session(from) || session(next))) return
|
||||
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
one = requestAnimationFrame(() => {
|
||||
one = 0
|
||||
two = requestAnimationFrame(() => {
|
||||
two = 0
|
||||
setState("nav", { dur: performance.now() - at, pending: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const obs: PerformanceObserver[] = []
|
||||
const fps: Array<{ at: number; dur: number }> = []
|
||||
const long: Array<{ at: number; dur: number }> = []
|
||||
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
||||
let hasLong = false
|
||||
let poll: number | undefined
|
||||
let raf = 0
|
||||
let last = 0
|
||||
let snap = 0
|
||||
|
||||
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
||||
while (list[0] && at - list[0].at > span) list.shift()
|
||||
}
|
||||
|
||||
const syncFrame = (at: number) => {
|
||||
trim(fps, span, at)
|
||||
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
||||
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
||||
const jank = fps.filter((entry) => entry.dur > 32).length
|
||||
batch(() => {
|
||||
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
||||
setState("gap", gap > 0 ? gap : undefined)
|
||||
setState("jank", jank)
|
||||
})
|
||||
}
|
||||
|
||||
const syncLong = (at = performance.now()) => {
|
||||
if (!hasLong) return
|
||||
trim(long, span, at)
|
||||
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
||||
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
||||
setState("long", { block, count: long.length, max })
|
||||
}
|
||||
|
||||
const syncInp = (at = performance.now()) => {
|
||||
for (const [key, entry] of seen) {
|
||||
if (at - entry.at > span) seen.delete(key)
|
||||
}
|
||||
let delay = 0
|
||||
let inp = 0
|
||||
for (const entry of seen.values()) {
|
||||
delay = Math.max(delay, entry.delay)
|
||||
inp = Math.max(inp, entry.dur)
|
||||
}
|
||||
batch(() => {
|
||||
setState("delay", delay > 0 ? delay : undefined)
|
||||
setState("inp", inp > 0 ? inp : undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const syncHeap = () => {
|
||||
const mem = (performance as Mem).memory
|
||||
if (!mem) return
|
||||
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
fps.length = 0
|
||||
long.length = 0
|
||||
seen.clear()
|
||||
last = 0
|
||||
snap = 0
|
||||
batch(() => {
|
||||
setState("fps", undefined)
|
||||
setState("gap", undefined)
|
||||
setState("jank", undefined)
|
||||
setState("delay", undefined)
|
||||
setState("inp", undefined)
|
||||
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
||||
if (typeof PerformanceObserver === "undefined") return false
|
||||
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
||||
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
||||
try {
|
||||
ob.observe(init)
|
||||
obs.push(ob)
|
||||
return true
|
||||
} catch {
|
||||
ob.disconnect()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
||||
const add = entries.reduce((sum, entry) => {
|
||||
const item = entry as Shift
|
||||
if (item.hadRecentInput) return sum
|
||||
return sum + item.value
|
||||
}, 0)
|
||||
if (add === 0) return
|
||||
setState("cls", (value) => (value ?? 0) + add)
|
||||
})
|
||||
) {
|
||||
setState("cls", 0)
|
||||
}
|
||||
|
||||
if (
|
||||
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
||||
const at = performance.now()
|
||||
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
||||
syncLong(at)
|
||||
})
|
||||
) {
|
||||
hasLong = true
|
||||
setState("long", { block: 0, count: 0, max: 0 })
|
||||
}
|
||||
|
||||
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
||||
for (const raw of entries) {
|
||||
const entry = raw as Evt
|
||||
if (entry.duration < 16) continue
|
||||
const key =
|
||||
entry.interactionId && entry.interactionId > 0
|
||||
? entry.interactionId
|
||||
: `${entry.name}:${Math.round(entry.startTime)}`
|
||||
const prev = seen.get(key)
|
||||
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
||||
seen.set(key, {
|
||||
at: entry.startTime,
|
||||
delay: Math.max(prev?.delay ?? 0, delay),
|
||||
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
||||
})
|
||||
if (seen.size <= 200) continue
|
||||
const first = seen.keys().next().value
|
||||
if (first !== undefined) seen.delete(first)
|
||||
}
|
||||
syncInp()
|
||||
})
|
||||
|
||||
const loop = (at: number) => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
raf = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (last === 0) {
|
||||
last = at
|
||||
raf = requestAnimationFrame(loop)
|
||||
return
|
||||
}
|
||||
|
||||
fps.push({ at, dur: at - last })
|
||||
last = at
|
||||
|
||||
if (at - snap >= 250) {
|
||||
snap = at
|
||||
syncFrame(at)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (raf !== 0) cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
if (poll === undefined) return
|
||||
clearInterval(poll)
|
||||
poll = undefined
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (poll === undefined) {
|
||||
poll = window.setInterval(() => {
|
||||
syncLong()
|
||||
syncInp()
|
||||
syncHeap()
|
||||
}, 1000)
|
||||
}
|
||||
if (raf !== 0) return
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const vis = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
reset()
|
||||
start()
|
||||
}
|
||||
|
||||
syncHeap()
|
||||
start()
|
||||
document.addEventListener("visibilitychange", vis)
|
||||
|
||||
onCleanup(() => {
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
stop()
|
||||
document.removeEventListener("visibilitychange", vis)
|
||||
for (const ob of obs) ob.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Development performance diagnostics"
|
||||
class="pointer-events-auto h-full min-h-0 w-[36px] shrink-0 overflow-y-auto text-text-on-interactive-base no-scrollbar sm:w-[38px]"
|
||||
style={{ "background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)" }}
|
||||
>
|
||||
<div class="flex min-h-full flex-col gap-0.5 py-2 font-mono">
|
||||
<Cell
|
||||
label="NAV"
|
||||
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
/>
|
||||
<Cell
|
||||
label="FPS"
|
||||
tip="Rolling frames per second over the last 5 seconds."
|
||||
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="FRM"
|
||||
tip="Worst frame time over the last 5 seconds."
|
||||
value={time(state.gap)}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="JNK"
|
||||
tip="Frames over 32ms in the last 5 seconds."
|
||||
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="LNG"
|
||||
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="DLY"
|
||||
tip="Worst observed input delay in the last 5 seconds."
|
||||
value={time(state.delay)}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="INP"
|
||||
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
|
||||
value={time(state.inp)}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="CLS"
|
||||
tip="Cumulative layout shift for the current app lifetime."
|
||||
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="MEM"
|
||||
tip={
|
||||
state.heap.used === undefined
|
||||
? "Used JS heap vs heap limit. Chromium only."
|
||||
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
|
||||
}
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
@@ -17,6 +18,7 @@ beforeAll(async () => {
|
||||
const mod = await import("./terminal")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||
migrateTerminalState = mod.migrateTerminalState
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
@@ -37,3 +39,44 @@ describe("getLegacyTerminalStorageKeys", () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateTerminalState", () => {
|
||||
test("drops invalid terminals and restores a valid active terminal", () => {
|
||||
expect(
|
||||
migrateTerminalState({
|
||||
active: "missing",
|
||||
all: [
|
||||
null,
|
||||
{ id: "one", title: "Terminal 2" },
|
||||
{ id: "one", title: "duplicate", titleNumber: 9 },
|
||||
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||
{ title: "no-id" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
active: "one",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 2", titleNumber: 2 },
|
||||
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps a valid active id", () => {
|
||||
expect(
|
||||
migrateTerminalState({
|
||||
active: "two",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 1" },
|
||||
{ id: "two", title: "shell", titleNumber: 7 },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
active: "two",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 1", titleNumber: 1 },
|
||||
{ id: "two", title: "shell", titleNumber: 7 },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,71 @@ export type LocalPTY = {
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_TERMINAL_SESSIONS = 20
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function num(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function numberFromTitle(title: string) {
|
||||
const match = title.match(/^Terminal (\d+)$/)
|
||||
if (!match) return
|
||||
const value = Number(match[1])
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
return value
|
||||
}
|
||||
|
||||
function pty(value: unknown): LocalPTY | undefined {
|
||||
if (!record(value)) return
|
||||
|
||||
const id = text(value.id)
|
||||
if (!id) return
|
||||
|
||||
const title = text(value.title) ?? ""
|
||||
const number = num(value.titleNumber)
|
||||
const rows = num(value.rows)
|
||||
const cols = num(value.cols)
|
||||
const buffer = text(value.buffer)
|
||||
const scrollY = num(value.scrollY)
|
||||
const cursor = num(value.cursor)
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
|
||||
...(rows !== undefined ? { rows } : {}),
|
||||
...(cols !== undefined ? { cols } : {}),
|
||||
...(buffer !== undefined ? { buffer } : {}),
|
||||
...(scrollY !== undefined ? { scrollY } : {}),
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateTerminalState(value: unknown) {
|
||||
if (!record(value)) return value
|
||||
|
||||
const seen = new Set<string>()
|
||||
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
|
||||
const next = pty(item)
|
||||
if (!next || seen.has(next.id)) return []
|
||||
seen.add(next.id)
|
||||
return [next]
|
||||
})
|
||||
|
||||
const active = text(value.active)
|
||||
|
||||
return {
|
||||
active: active && seen.has(active) ? active : all[0]?.id,
|
||||
all,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||
return `${dir}:${WORKSPACE_KEY}`
|
||||
}
|
||||
@@ -71,16 +136,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
|
||||
const numberFromTitle = (title: string) => {
|
||||
const match = title.match(/^Terminal (\d+)$/)
|
||||
if (!match) return
|
||||
const value = Number(match[1])
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
return value
|
||||
}
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.workspace(dir, "terminal", legacy),
|
||||
{
|
||||
...Persist.workspace(dir, "terminal", legacy),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
@@ -128,26 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const meta = { migrated: false }
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.migrated) return
|
||||
meta.migrated = true
|
||||
|
||||
setStore("all", (all) => {
|
||||
const next = all.map((pty) => {
|
||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||
if (direct !== undefined) return pty
|
||||
const parsed = numberFromTitle(pty.title)
|
||||
if (parsed === undefined) return pty
|
||||
return { ...pty, titleNumber: parsed }
|
||||
})
|
||||
if (next.every((pty, index) => pty === all[index])) return all
|
||||
return next
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => store.all),
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
@@ -424,6 +425,17 @@ export default function Layout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
e.details?.type === "question.replied" ||
|
||||
e.details?.type === "question.rejected" ||
|
||||
e.details?.type === "permission.replied"
|
||||
) {
|
||||
const props = e.details.properties as { sessionID: string }
|
||||
const sessionKey = `${e.name}:${props.sessionID}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||
const title =
|
||||
e.details.type === "permission.asked"
|
||||
@@ -2124,193 +2136,204 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar />
|
||||
<div class="flex-1 min-h-0 relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
<SidebarContent
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
<SidebarContent
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay
|
||||
projects={() => layout.projects.list()}
|
||||
activeProject={() => store.activeProject}
|
||||
/>
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setSizing(true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setSizing(true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent
|
||||
mobile
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay
|
||||
projects={() => layout.projects.list()}
|
||||
activeProject={() => store.activeProject}
|
||||
/>
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!sizing(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent
|
||||
mobile
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!sizing(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peek()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peek()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,6 @@ import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
@@ -486,20 +485,49 @@ export default function Page() {
|
||||
return "main"
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||
return found ?? lastUserMessage()
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
messageMark = scrollMark
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
|
||||
const anchor = (id: string) => `message-${id}`
|
||||
|
||||
const cursor = () => {
|
||||
const root = scroller
|
||||
if (!root) return store.messageId
|
||||
|
||||
const box = root.getBoundingClientRect()
|
||||
const line = box.top + 100
|
||||
const list = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.map((el) => {
|
||||
const id = el.dataset.messageId
|
||||
if (!id) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { id, top: rect.top, bottom: rect.bottom }
|
||||
})
|
||||
.filter((item): item is { id: string; top: number; bottom: number } => !!item)
|
||||
|
||||
const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
|
||||
const hit = shown.find((item) => item.top <= line && item.bottom >= line)
|
||||
if (hit) return hit.id
|
||||
|
||||
const near = [...shown].sort((a, b) => {
|
||||
const da = Math.abs(a.top - line)
|
||||
const db = Math.abs(b.top - line)
|
||||
if (da !== db) return da - db
|
||||
return a.top - b.top
|
||||
})[0]
|
||||
if (near) return near.id
|
||||
|
||||
return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId
|
||||
}
|
||||
|
||||
function navigateMessageByOffset(offset: number) {
|
||||
const msgs = visibleUserMessages()
|
||||
if (msgs.length === 0) return
|
||||
|
||||
const current = store.messageId
|
||||
const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor()
|
||||
const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
|
||||
const currentIndex = base === -1 ? msgs.length : base
|
||||
const targetIndex = currentIndex + offset
|
||||
@@ -572,6 +600,8 @@ export default function Page() {
|
||||
let dockHeight = 0
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
|
||||
const scrollGestureWindowMs = 250
|
||||
|
||||
@@ -616,6 +646,7 @@ export default function Page() {
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -1110,12 +1141,6 @@ export default function Page() {
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
let scrollStateTarget: HTMLDivElement | undefined
|
||||
const scrollSpy = createScrollSpy({
|
||||
onActive: (id) => {
|
||||
if (id === store.messageId) return
|
||||
setStore("messageId", id)
|
||||
},
|
||||
})
|
||||
|
||||
const updateScrollState = (el: HTMLDivElement) => {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
@@ -1163,31 +1188,21 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
scrollSpy.clear()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const anchor = (id: string) => `message-${id}`
|
||||
|
||||
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||
scroller = el
|
||||
autoScroll.scrollRef(el)
|
||||
scrollSpy.setContainer(el)
|
||||
if (el) scheduleScrollState(el)
|
||||
}
|
||||
|
||||
const markUserScroll = () => {
|
||||
scrollMark += 1
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => content,
|
||||
() => {
|
||||
const el = scroller
|
||||
if (el) scheduleScrollState(el)
|
||||
scrollSpy.markDirty()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1220,7 +1235,6 @@ export default function Page() {
|
||||
if (stick) autoScroll.forceScrollToBottom()
|
||||
|
||||
if (el) scheduleScrollState(el)
|
||||
scrollSpy.markDirty()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1248,7 +1262,6 @@ export default function Page() {
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
scrollSpy.destroy()
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
})
|
||||
@@ -1280,7 +1293,7 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={activeMessage()}>
|
||||
<Show when={lastUserMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
@@ -1300,8 +1313,7 @@ export default function Page() {
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onUserScroll={markUserScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
centered={centered()}
|
||||
@@ -1320,8 +1332,6 @@ export default function Page() {
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
@@ -192,8 +193,7 @@ export function MessageTimeline(props: {
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: () => boolean
|
||||
isDesktop: boolean
|
||||
onScrollSpyScroll: () => void
|
||||
onUserScroll: () => void
|
||||
onTurnBackfillScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
centered: boolean
|
||||
@@ -204,8 +204,6 @@ export function MessageTimeline(props: {
|
||||
onLoadEarlier: () => void
|
||||
renderedUserMessages: UserMessage[]
|
||||
anchor: (id: string) => string
|
||||
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
||||
onUnregisterMessage: (id: string) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
@@ -235,6 +233,40 @@ export function MessageTimeline(props: {
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
open: false,
|
||||
show: false,
|
||||
fade: false,
|
||||
})
|
||||
|
||||
let f: number | undefined
|
||||
const clear = () => {
|
||||
if (f !== undefined) window.clearTimeout(f)
|
||||
f = undefined
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
createEffect(
|
||||
on(
|
||||
working,
|
||||
(on, prev) => {
|
||||
clear()
|
||||
if (on) {
|
||||
setSlot({ open: true, show: true, fade: false })
|
||||
return
|
||||
}
|
||||
if (prev) {
|
||||
setSlot({ open: false, show: true, fade: true })
|
||||
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||
return
|
||||
}
|
||||
setSlot({ open: false, show: false, fade: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
@@ -539,9 +571,9 @@ export function MessageTimeline(props: {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
props.onTurnBackfillScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onUserScroll()
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
if (props.isDesktop) props.onScrollSpyScroll()
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
@@ -573,43 +605,64 @@ export function MessageTimeline(props: {
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
<Show when={slot.show}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
@@ -707,10 +760,6 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, messageID)
|
||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
|
||||
|
||||
const rect = (top: number, height = 80): DOMRect =>
|
||||
({
|
||||
x: 0,
|
||||
y: top,
|
||||
top,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: top + height,
|
||||
width: 800,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
|
||||
const setRect = (el: Element, top: number, height = 80) => {
|
||||
Object.defineProperty(el, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => rect(top, height),
|
||||
})
|
||||
}
|
||||
|
||||
describe("pickVisibleId", () => {
|
||||
test("prefers higher intersection ratio", () => {
|
||||
const id = pickVisibleId(
|
||||
[
|
||||
{ id: "a", ratio: 0.2, top: 100 },
|
||||
{ id: "b", ratio: 0.8, top: 300 },
|
||||
],
|
||||
120,
|
||||
)
|
||||
|
||||
expect(id).toBe("b")
|
||||
})
|
||||
|
||||
test("breaks ratio ties by nearest line", () => {
|
||||
const id = pickVisibleId(
|
||||
[
|
||||
{ id: "a", ratio: 0.5, top: 90 },
|
||||
{ id: "b", ratio: 0.5, top: 140 },
|
||||
],
|
||||
130,
|
||||
)
|
||||
|
||||
expect(id).toBe("b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickOffsetId", () => {
|
||||
test("uses binary search cutoff", () => {
|
||||
const id = pickOffsetId(
|
||||
[
|
||||
{ id: "a", top: 0 },
|
||||
{ id: "b", top: 200 },
|
||||
{ id: "c", top: 400 },
|
||||
],
|
||||
350,
|
||||
)
|
||||
|
||||
expect(id).toBe("b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createScrollSpy fallback", () => {
|
||||
test("tracks active id from offsets and dirty refresh", () => {
|
||||
const active: string[] = []
|
||||
const root = document.createElement("div") as HTMLDivElement
|
||||
const one = document.createElement("div")
|
||||
const two = document.createElement("div")
|
||||
const three = document.createElement("div")
|
||||
|
||||
root.append(one, two, three)
|
||||
document.body.append(root)
|
||||
|
||||
Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
|
||||
setRect(root, 0, 800)
|
||||
setRect(one, -250)
|
||||
setRect(two, -50)
|
||||
setRect(three, 150)
|
||||
|
||||
const queue: FrameRequestCallback[] = []
|
||||
const flush = () => {
|
||||
const run = [...queue]
|
||||
queue.length = 0
|
||||
for (const cb of run) cb(0)
|
||||
}
|
||||
|
||||
const spy = createScrollSpy({
|
||||
onActive: (id) => active.push(id),
|
||||
raf: (cb) => (queue.push(cb), queue.length),
|
||||
caf: () => {},
|
||||
IntersectionObserver: undefined,
|
||||
ResizeObserver: undefined,
|
||||
MutationObserver: undefined,
|
||||
})
|
||||
|
||||
spy.setContainer(root)
|
||||
spy.register(one, "a")
|
||||
spy.register(two, "b")
|
||||
spy.register(three, "c")
|
||||
spy.onScroll()
|
||||
flush()
|
||||
|
||||
expect(spy.getActiveId()).toBe("b")
|
||||
expect(active.at(-1)).toBe("b")
|
||||
|
||||
root.scrollTop = 450
|
||||
setRect(one, -450)
|
||||
setRect(two, -250)
|
||||
setRect(three, -50)
|
||||
spy.onScroll()
|
||||
flush()
|
||||
expect(spy.getActiveId()).toBe("c")
|
||||
|
||||
root.scrollTop = 250
|
||||
setRect(one, -250)
|
||||
setRect(two, 250)
|
||||
setRect(three, 150)
|
||||
spy.markDirty()
|
||||
spy.onScroll()
|
||||
flush()
|
||||
expect(spy.getActiveId()).toBe("a")
|
||||
|
||||
spy.destroy()
|
||||
})
|
||||
})
|
||||
@@ -1,275 +0,0 @@
|
||||
type Visible = {
|
||||
id: string
|
||||
ratio: number
|
||||
top: number
|
||||
}
|
||||
|
||||
type Offset = {
|
||||
id: string
|
||||
top: number
|
||||
}
|
||||
|
||||
type Input = {
|
||||
onActive: (id: string) => void
|
||||
raf?: (cb: FrameRequestCallback) => number
|
||||
caf?: (id: number) => void
|
||||
IntersectionObserver?: typeof globalThis.IntersectionObserver
|
||||
ResizeObserver?: typeof globalThis.ResizeObserver
|
||||
MutationObserver?: typeof globalThis.MutationObserver
|
||||
}
|
||||
|
||||
export const pickVisibleId = (list: Visible[], line: number) => {
|
||||
if (list.length === 0) return
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
if (b.ratio !== a.ratio) return b.ratio - a.ratio
|
||||
|
||||
const da = Math.abs(a.top - line)
|
||||
const db = Math.abs(b.top - line)
|
||||
if (da !== db) return da - db
|
||||
|
||||
return a.top - b.top
|
||||
})
|
||||
|
||||
return sorted[0]?.id
|
||||
}
|
||||
|
||||
export const pickOffsetId = (list: Offset[], cutoff: number) => {
|
||||
if (list.length === 0) return
|
||||
|
||||
let lo = 0
|
||||
let hi = list.length - 1
|
||||
let out = 0
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
const top = list[mid]?.top
|
||||
if (top === undefined) break
|
||||
|
||||
if (top <= cutoff) {
|
||||
out = mid
|
||||
lo = mid + 1
|
||||
continue
|
||||
}
|
||||
|
||||
hi = mid - 1
|
||||
}
|
||||
|
||||
return list[out]?.id
|
||||
}
|
||||
|
||||
export const createScrollSpy = (input: Input) => {
|
||||
const raf = input.raf ?? requestAnimationFrame
|
||||
const caf = input.caf ?? cancelAnimationFrame
|
||||
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
|
||||
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
|
||||
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let io: IntersectionObserver | undefined
|
||||
let ro: ResizeObserver | undefined
|
||||
let mo: MutationObserver | undefined
|
||||
let frame: number | undefined
|
||||
let active: string | undefined
|
||||
let dirty = true
|
||||
|
||||
const node = new Map<string, HTMLElement>()
|
||||
const id = new WeakMap<HTMLElement, string>()
|
||||
const visible = new Map<string, { ratio: number; top: number }>()
|
||||
let offset: Offset[] = []
|
||||
|
||||
const schedule = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = raf(() => {
|
||||
frame = undefined
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
const refreshOffset = () => {
|
||||
const el = root
|
||||
if (!el) {
|
||||
offset = []
|
||||
dirty = false
|
||||
return
|
||||
}
|
||||
|
||||
const base = el.getBoundingClientRect().top
|
||||
offset = [...node].map(([next, item]) => ({
|
||||
id: next,
|
||||
top: item.getBoundingClientRect().top - base + el.scrollTop,
|
||||
}))
|
||||
offset.sort((a, b) => a.top - b.top)
|
||||
dirty = false
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const el = root
|
||||
if (!el) return
|
||||
|
||||
const line = el.getBoundingClientRect().top + 100
|
||||
const next =
|
||||
pickVisibleId(
|
||||
[...visible].map(([k, v]) => ({
|
||||
id: k,
|
||||
ratio: v.ratio,
|
||||
top: v.top,
|
||||
})),
|
||||
line,
|
||||
) ??
|
||||
(() => {
|
||||
if (dirty) refreshOffset()
|
||||
return pickOffsetId(offset, el.scrollTop + 100)
|
||||
})()
|
||||
|
||||
if (!next || next === active) return
|
||||
active = next
|
||||
input.onActive(next)
|
||||
}
|
||||
|
||||
const observe = () => {
|
||||
const el = root
|
||||
if (!el) return
|
||||
|
||||
io?.disconnect()
|
||||
io = undefined
|
||||
if (CtorIO) {
|
||||
try {
|
||||
io = new CtorIO(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const item = entry.target
|
||||
if (!(item instanceof HTMLElement)) continue
|
||||
const key = id.get(item)
|
||||
if (!key) continue
|
||||
|
||||
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
|
||||
visible.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
visible.set(key, {
|
||||
ratio: entry.intersectionRatio,
|
||||
top: entry.boundingClientRect.top,
|
||||
})
|
||||
}
|
||||
|
||||
schedule()
|
||||
},
|
||||
{
|
||||
root: el,
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
io = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (io) {
|
||||
for (const item of node.values()) io.observe(item)
|
||||
}
|
||||
|
||||
ro?.disconnect()
|
||||
ro = undefined
|
||||
if (CtorRO) {
|
||||
ro = new CtorRO(() => {
|
||||
dirty = true
|
||||
schedule()
|
||||
})
|
||||
ro.observe(el)
|
||||
for (const item of node.values()) ro.observe(item)
|
||||
}
|
||||
|
||||
mo?.disconnect()
|
||||
mo = undefined
|
||||
if (CtorMO) {
|
||||
mo = new CtorMO(() => {
|
||||
dirty = true
|
||||
schedule()
|
||||
})
|
||||
mo.observe(el, { subtree: true, childList: true, characterData: true })
|
||||
}
|
||||
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const setContainer = (el?: HTMLDivElement) => {
|
||||
if (root === el) return
|
||||
|
||||
root = el
|
||||
visible.clear()
|
||||
active = undefined
|
||||
observe()
|
||||
}
|
||||
|
||||
const register = (el: HTMLElement, key: string) => {
|
||||
const prev = node.get(key)
|
||||
if (prev && prev !== el) {
|
||||
io?.unobserve(prev)
|
||||
ro?.unobserve(prev)
|
||||
}
|
||||
|
||||
node.set(key, el)
|
||||
id.set(el, key)
|
||||
if (io) io.observe(el)
|
||||
if (ro) ro.observe(el)
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const unregister = (key: string) => {
|
||||
const item = node.get(key)
|
||||
if (!item) return
|
||||
|
||||
io?.unobserve(item)
|
||||
ro?.unobserve(item)
|
||||
node.delete(key)
|
||||
visible.delete(key)
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const markDirty = () => {
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
for (const item of node.values()) {
|
||||
io?.unobserve(item)
|
||||
ro?.unobserve(item)
|
||||
}
|
||||
|
||||
node.clear()
|
||||
visible.clear()
|
||||
offset = []
|
||||
active = undefined
|
||||
dirty = true
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
if (frame !== undefined) caf(frame)
|
||||
frame = undefined
|
||||
clear()
|
||||
io?.disconnect()
|
||||
ro?.disconnect()
|
||||
mo?.disconnect()
|
||||
io = undefined
|
||||
ro = undefined
|
||||
mo = undefined
|
||||
root = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
setContainer,
|
||||
register,
|
||||
unregister,
|
||||
onScroll: schedule,
|
||||
markDirty,
|
||||
clear,
|
||||
destroy,
|
||||
getActiveId: () => active,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { messageIdFromHash } from "./message-id-from-hash"
|
||||
|
||||
export { messageIdFromHash } from "./message-id-from-hash"
|
||||
@@ -26,17 +26,38 @@ export const useSessionHashScroll = (input: {
|
||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||
let pendingKey = ""
|
||||
let clearing = false
|
||||
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const frames = new Set<number>()
|
||||
const queue = (fn: () => void) => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
frames.delete(id)
|
||||
fn()
|
||||
})
|
||||
frames.add(id)
|
||||
}
|
||||
const cancel = () => {
|
||||
for (const id of frames) cancelAnimationFrame(id)
|
||||
frames.clear()
|
||||
}
|
||||
|
||||
const clearMessageHash = () => {
|
||||
cancel()
|
||||
input.consumePendingMessage(input.sessionKey())
|
||||
if (input.pendingMessage()) input.setPendingMessage(undefined)
|
||||
if (!location.hash) return
|
||||
clearing = true
|
||||
navigate(location.pathname + location.search, { replace: true })
|
||||
}
|
||||
|
||||
const updateHash = (id: string) => {
|
||||
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
|
||||
const hash = `#${input.anchor(id)}`
|
||||
if (location.hash === hash) return
|
||||
clearing = false
|
||||
navigate(location.pathname + location.search + hash, {
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
@@ -54,51 +75,37 @@ export const useSessionHashScroll = (input: {
|
||||
return true
|
||||
}
|
||||
|
||||
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
||||
const el = document.getElementById(input.anchor(id))
|
||||
if (el) return scrollToElement(el, behavior)
|
||||
if (left <= 0) return false
|
||||
queue(() => {
|
||||
seek(id, behavior, left - 1)
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
console.log({ message, behavior })
|
||||
cancel()
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
|
||||
const index = messageIndex().get(message.id) ?? -1
|
||||
if (index !== -1 && index < input.turnStart()) {
|
||||
input.setTurnStart(index)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(input.anchor(message.id))
|
||||
if (!el) {
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
scrollToElement(next, behavior)
|
||||
})
|
||||
return
|
||||
}
|
||||
scrollToElement(el, behavior)
|
||||
queue(() => {
|
||||
seek(message.id, behavior)
|
||||
})
|
||||
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const el = document.getElementById(input.anchor(message.id))
|
||||
if (!el) {
|
||||
updateHash(message.id)
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
return
|
||||
}
|
||||
if (scrollToElement(el, behavior)) {
|
||||
if (seek(message.id, behavior)) {
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
updateHash(message.id)
|
||||
}
|
||||
|
||||
@@ -135,9 +142,11 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
location.hash
|
||||
const hash = location.hash
|
||||
if (!hash) clearing = false
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
cancel()
|
||||
queue(() => applyHash("auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -159,16 +168,19 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetId) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (input.currentMessageId() === targetId) return
|
||||
|
||||
const pending = input.pendingMessage() === targetId
|
||||
const msg = messageById().get(targetId)
|
||||
if (!msg) return
|
||||
|
||||
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
|
||||
if (pending) input.setPendingMessage(undefined)
|
||||
if (input.currentMessageId() === targetId && !pending) return
|
||||
|
||||
input.autoScroll.pause()
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
cancel()
|
||||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -177,6 +189,8 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(cancel)
|
||||
|
||||
return {
|
||||
clearMessageHash,
|
||||
scrollToMessage,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"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.23",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.23"
|
||||
version = "1.2.24"
|
||||
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.23/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/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.23/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/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.23/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/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.23/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"$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.23",
|
||||
"version": "1.2.24",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -41,6 +41,7 @@
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
@@ -90,8 +91,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-20260307-536c401b",
|
||||
"@opentui/solid": "0.0.0-20260307-536c401b",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -121,6 +122,7 @@
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"semver": "^7.6.3",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -59,6 +59,7 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const plugin = createSolidTransformPlugin({ mode: "build" })
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -173,7 +174,7 @@ for (const item of targets) {
|
||||
await Bun.build({
|
||||
conditions: ["browser"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin],
|
||||
plugins: [plugin],
|
||||
sourcemap: "external",
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
|
||||
import { Log } from "../util/log"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Hash } from "../util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { semver } from "bun"
|
||||
import semver from "semver"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
@@ -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",
|
||||
@@ -45,6 +58,6 @@ export namespace PackageRegistry {
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.order(cachedVersion, latestVersion) === -1
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { $ } from "bun"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
|
||||
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
@@ -493,6 +494,26 @@ 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) {
|
||||
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
|
||||
const branch = await checkoutNewBranch(branchPrefix)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const response = await chat(userPrompt, promptFiles)
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
|
||||
if (switched) {
|
||||
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
|
||||
// Local PR
|
||||
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
|
||||
await checkoutLocalBranch(prData)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const dataPrompt = buildPromptDataForPR(prData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
|
||||
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
|
||||
// Fork PR
|
||||
else {
|
||||
const forkBranch = await checkoutForkBranch(prData)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const dataPrompt = buildPromptDataForPR(prData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
|
||||
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
|
||||
// Issue
|
||||
else {
|
||||
const branch = await checkoutNewBranch("issue")
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const issueData = await fetchIssue()
|
||||
const dataPrompt = buildPromptDataForIssue(issueData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
|
||||
exitCode = 1
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
let msg = e
|
||||
if (e instanceof $.ShellError) {
|
||||
if (e instanceof Process.RunFailedError) {
|
||||
msg = e.stderr.toString()
|
||||
} else if (e instanceof Error) {
|
||||
msg = e.message
|
||||
@@ -1049,29 +1070,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 $`git config --local --get ${config}`.nothrow()
|
||||
const ret = await gitStatus(["config", "--local", "--get", config])
|
||||
if (ret.exitCode === 0) {
|
||||
gitConfig = ret.stdout.toString().trim()
|
||||
await $`git config --local --unset-all ${config}`
|
||||
await gitRun(["config", "--local", "--unset-all", config])
|
||||
}
|
||||
|
||||
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
|
||||
|
||||
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"`
|
||||
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`])
|
||||
}
|
||||
|
||||
async function restoreGitConfig() {
|
||||
if (gitConfig === undefined) return
|
||||
const config = "http.https://github.com/.extraheader"
|
||||
await $`git config --local ${config} "${gitConfig}"`
|
||||
await gitRun(["config", "--local", config, gitConfig])
|
||||
}
|
||||
|
||||
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
|
||||
console.log("Checking out new branch...")
|
||||
const branch = generateBranchName(type)
|
||||
await $`git checkout -b ${branch}`
|
||||
await gitRun(["checkout", "-b", branch])
|
||||
return branch
|
||||
}
|
||||
|
||||
@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
|
||||
const branch = pr.headRefName
|
||||
const depth = Math.max(pr.commits.totalCount, 20)
|
||||
|
||||
await $`git fetch origin --depth=${depth} ${branch}`
|
||||
await $`git checkout ${branch}`
|
||||
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
|
||||
await gitRun(["checkout", branch])
|
||||
}
|
||||
|
||||
async function checkoutForkBranch(pr: GitHubPullRequest) {
|
||||
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
|
||||
const localBranch = generateBranchName("pr")
|
||||
const depth = Math.max(pr.commits.totalCount, 20)
|
||||
|
||||
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}`
|
||||
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}`])
|
||||
return localBranch
|
||||
}
|
||||
|
||||
@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
|
||||
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
|
||||
console.log("Pushing to new branch...")
|
||||
if (commit) {
|
||||
await $`git add .`
|
||||
await gitRun(["add", "."])
|
||||
if (isSchedule) {
|
||||
// No co-author for scheduled events - the schedule is operating as the repo
|
||||
await $`git commit -m "${summary}"`
|
||||
await commitChanges(summary)
|
||||
} else {
|
||||
await $`git commit -m "${summary}
|
||||
|
||||
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await commitChanges(summary, actor)
|
||||
}
|
||||
}
|
||||
await $`git push -u origin ${branch}`
|
||||
await gitRun(["push", "-u", "origin", branch])
|
||||
}
|
||||
|
||||
async function pushToLocalBranch(summary: string, commit: boolean) {
|
||||
console.log("Pushing to local branch...")
|
||||
if (commit) {
|
||||
await $`git add .`
|
||||
await $`git commit -m "${summary}
|
||||
|
||||
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await gitRun(["add", "."])
|
||||
await commitChanges(summary, actor)
|
||||
}
|
||||
await $`git push`
|
||||
await gitRun(["push"])
|
||||
}
|
||||
|
||||
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
|
||||
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
const remoteBranch = pr.headRefName
|
||||
|
||||
if (commit) {
|
||||
await $`git add .`
|
||||
await $`git commit -m "${summary}
|
||||
|
||||
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await gitRun(["add", "."])
|
||||
await commitChanges(summary, actor)
|
||||
}
|
||||
await $`git push fork HEAD:${remoteBranch}`
|
||||
await gitRun(["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 $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
|
||||
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
if (current !== expectedBranch) {
|
||||
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
|
||||
return { dirty: true, uncommittedChanges: false, switched: true }
|
||||
}
|
||||
|
||||
const ret = await $`git status --porcelain`
|
||||
const ret = await gitStatus(["status", "--porcelain"])
|
||||
const status = ret.stdout.toString().trim()
|
||||
if (status.length > 0) {
|
||||
return { dirty: true, uncommittedChanges: true, switched: false }
|
||||
}
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
return {
|
||||
dirty: head !== originalHead,
|
||||
uncommittedChanges: false,
|
||||
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
// 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 $`git rev-list --count ${base}..${head}`.nothrow()
|
||||
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
|
||||
if (result.exitCode !== 0) {
|
||||
console.log(`rev-list failed, fetching origin/${base}...`)
|
||||
await $`git fetch origin ${base} --depth=1`.nothrow()
|
||||
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
|
||||
await gitStatus(["fetch", "origin", base, "--depth=1"])
|
||||
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
|
||||
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
|
||||
return parseInt(retry.stdout.toString().trim()) > 0
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { $ } from "bun"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export const PrCommand = cmd({
|
||||
command: "pr <number>",
|
||||
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
|
||||
UI.println(`Fetching and checking out PR #${prNumber}...`)
|
||||
|
||||
// Use gh pr checkout with custom branch name
|
||||
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
|
||||
const result = await Process.run(
|
||||
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
|
||||
{
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
if (result.code !== 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 $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
|
||||
const prInfoResult = await Process.text(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"view",
|
||||
`${prNumber}`,
|
||||
"--json",
|
||||
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
|
||||
],
|
||||
{ nothrow: true },
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
|
||||
if (prInfoResult.exitCode === 0) {
|
||||
const prInfoText = prInfoResult.text()
|
||||
if (prInfoResult.code === 0) {
|
||||
const prInfoText = prInfoResult.text
|
||||
if (prInfoText.trim()) {
|
||||
const prInfo = JSON.parse(prInfoText)
|
||||
|
||||
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = (await $`git remote`.nothrow().text()).trim()
|
||||
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
|
||||
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
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}`.nothrow()
|
||||
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for opencode session link in PR body
|
||||
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
|
||||
UI.println(`Found opencode session: ${sessionUrl}`)
|
||||
UI.println(`Importing session...`)
|
||||
|
||||
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
|
||||
if (importResult.exitCode === 0) {
|
||||
const importOutput = importResult.text().trim()
|
||||
const importResult = await Process.text(["opencode", "import", sessionUrl], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (importResult.code === 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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Argv } from "yargs"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { pathToFileURL } from "url"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
@@ -667,7 +667,7 @@ export const RunCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
return Server.App().fetch(request)
|
||||
return Server.Default().fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
await execute(sdk)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig, type ParsedKey } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
createEffect,
|
||||
createMemo,
|
||||
untrack,
|
||||
ErrorBoundary,
|
||||
createSignal,
|
||||
onMount,
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Installation } from "@/installation"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { Dialog as DialogUI, DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
@@ -20,7 +32,8 @@ 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 { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
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 +41,9 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { DialogPrompt } from "./ui/dialog-prompt"
|
||||
import { DialogSelect } from "./ui/dialog-select"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session"
|
||||
@@ -40,6 +56,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 +121,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
|
||||
@@ -128,73 +165,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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,12 +228,226 @@ function App() {
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme, mode, setMode } = useTheme()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode } = themeState
|
||||
const sync = useSync()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const routes = new Map<string, { key: symbol; render: TuiRouteDefinition["render"] }[]>()
|
||||
const [routeRev, setRouteRev] = createSignal(0)
|
||||
const routeView = (name: string) => {
|
||||
routeRev()
|
||||
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)
|
||||
}
|
||||
setRouteRev((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)
|
||||
}
|
||||
setRouteRev((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,
|
||||
})
|
||||
},
|
||||
dialog: {
|
||||
replace(render, onClose) {
|
||||
dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return dialog.stack.length > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
parse(evt: ParsedKey) {
|
||||
return keybind.parse(evt)
|
||||
},
|
||||
match(key, evt: ParsedKey) {
|
||||
return keybind.match(key, evt)
|
||||
},
|
||||
print(key) {
|
||||
return keybind.print(key)
|
||||
},
|
||||
create(defaults, overrides) {
|
||||
return keybind.create(defaults, overrides)
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
return theme
|
||||
},
|
||||
get selected() {
|
||||
return themeState.selected
|
||||
},
|
||||
has(name) {
|
||||
return themeState.has(name)
|
||||
},
|
||||
set(name) {
|
||||
return themeState.set(name)
|
||||
},
|
||||
async install(_jsonPath) {
|
||||
throw new Error("theme.install is only available in plugin context")
|
||||
},
|
||||
mode() {
|
||||
return themeState.mode()
|
||||
},
|
||||
get ready() {
|
||||
return themeState.ready
|
||||
},
|
||||
},
|
||||
}
|
||||
const [ready, setReady] = createSignal(false)
|
||||
TuiPlugin.init({
|
||||
client: sdk.client,
|
||||
event: sdk.event,
|
||||
renderer,
|
||||
api,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load TUI plugins", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
@@ -255,10 +490,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
|
||||
@@ -275,9 +506,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}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -371,6 +606,22 @@ function App() {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
? [
|
||||
{
|
||||
title: "Manage workspaces",
|
||||
value: "workspace.list",
|
||||
category: "Workspace",
|
||||
suggested: true,
|
||||
slash: {
|
||||
name: "workspaces",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "New session",
|
||||
suggested: route.data.type === "session",
|
||||
@@ -732,29 +983,58 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
const plugin = createMemo(() => {
|
||||
if (route.data.type !== "plugin") return
|
||||
const render = routeView(route.data.id)
|
||||
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
|
||||
return render({ params: route.data.data })
|
||||
})
|
||||
|
||||
if (!Selection.copy(renderer, toast)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
return (
|
||||
<Show when={ready()}>
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!Selection.copy(renderer, toast)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)
|
||||
}
|
||||
>
|
||||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
{plugin()}
|
||||
<TuiPlugin.Slot name="app" />
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginRouteMissing(props: { id: string; onHome: () => void }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
|
||||
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.text}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
forceCreate?: boolean
|
||||
}) {
|
||||
const cacheSession = (session: Session) => {
|
||||
input.sync.set(
|
||||
"session",
|
||||
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: input.sdk.url,
|
||||
fetch: input.sdk.fetch,
|
||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
})
|
||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||
const session = listed?.data?.[0]
|
||||
if (session?.id) {
|
||||
cacheSession(session)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: session.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
let created: Session | undefined
|
||||
while (!created) {
|
||||
const result = await client.session.create({}).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await Bun.sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
created = result.data
|
||||
}
|
||||
cacheSession(created)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: created.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
}
|
||||
|
||||
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const createWorkspace = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||
console.log(err)
|
||||
return undefined
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: "Failed to create workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating") return
|
||||
void createWorkspace(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const keybind = useKeybind()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
|
||||
|
||||
const open = (workspaceID: string, forceCreate?: boolean) =>
|
||||
openWorkspace({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
forceCreate,
|
||||
})
|
||||
|
||||
async function selectWorkspace(workspaceID: string) {
|
||||
if (workspaceID === "__local__") {
|
||||
if (localCount() > 0) {
|
||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||
return
|
||||
}
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const count = counts()[workspaceID]
|
||||
if (count && count > 0) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await open(workspaceID)
|
||||
return
|
||||
}
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||
if (listed?.data?.length) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
await open(workspaceID)
|
||||
}
|
||||
|
||||
const currentWorkspaceID = createMemo(() => {
|
||||
if (route.data.type === "session") {
|
||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
||||
}
|
||||
return "__local__"
|
||||
})
|
||||
|
||||
const localCount = createMemo(
|
||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||
)
|
||||
|
||||
let run = 0
|
||||
createEffect(() => {
|
||||
const workspaces = sync.data.workspaceList
|
||||
const next = ++run
|
||||
if (!workspaces.length) {
|
||||
setCounts({})
|
||||
return
|
||||
}
|
||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||
void Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspace.id,
|
||||
})
|
||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||
}),
|
||||
).then((entries) => {
|
||||
if (run !== next) return
|
||||
setCounts(Object.fromEntries(entries))
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
title: "Local",
|
||||
value: "__local__",
|
||||
category: "Workspace",
|
||||
description: "Use the local machine",
|
||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||
},
|
||||
...sync.data.workspaceList.map((workspace) => {
|
||||
const count = counts()[workspace.id]
|
||||
return {
|
||||
title:
|
||||
toDelete() === workspace.id
|
||||
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
|
||||
: workspace.id,
|
||||
value: workspace.id,
|
||||
category: workspace.type,
|
||||
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
|
||||
footer:
|
||||
count === undefined
|
||||
? "Loading sessions..."
|
||||
: count === null
|
||||
? "Sessions unavailable"
|
||||
: `${count} session${count === 1 ? "" : "s"}`,
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "+ New workspace",
|
||||
value: "__create__",
|
||||
category: "Actions",
|
||||
description: "Create a new workspace",
|
||||
},
|
||||
])
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sync.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
current={currentWorkspaceID()}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
setToDelete(undefined)
|
||||
if (option.value === "__create__") {
|
||||
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
|
||||
return
|
||||
}
|
||||
void selectWorkspace(option.value)
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === "__create__" || option.value === "__local__") return
|
||||
if (toDelete() !== option.value) {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
|
||||
setToDelete(undefined)
|
||||
if (result?.error) {
|
||||
toast.show({
|
||||
message: "Failed to delete workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (currentWorkspaceID() === option.value) {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -539,12 +539,25 @@ export function Prompt(props: PromptProps) {
|
||||
promptModelWarning()
|
||||
return
|
||||
}
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
|
||||
return sessionID
|
||||
})()
|
||||
|
||||
let sessionID = props.sessionID
|
||||
if (sessionID == null) {
|
||||
const res = await sdk.client.session.create({})
|
||||
|
||||
if (res.error) {
|
||||
console.log("Creating a session failed:", res.error)
|
||||
|
||||
toast.show({
|
||||
message: "Creating a session failed. Open console for more details.",
|
||||
variant: "error",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionID = res.data.id
|
||||
}
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
let inputText = store.prompt.input
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { DialogSessionRename } from "../dialog-session-rename"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
const [listed, listedActions] = createResource(
|
||||
() => props.workspaceID,
|
||||
async (workspaceID) => {
|
||||
if (!workspaceID) return undefined
|
||||
const result = await sdk.client.session.list({ roots: true })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query || props.localOnly) return undefined
|
||||
const result = await sdk.client.session.list({
|
||||
search: query,
|
||||
limit: 30,
|
||||
...(props.workspaceID ? { roots: true } : {}),
|
||||
})
|
||||
return result.data ?? []
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
if (searchResults()) return searchResults()!
|
||||
if (props.workspaceID) return listed() ?? []
|
||||
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
|
||||
return sync.data.session
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => {
|
||||
if (x.parentID !== undefined) return false
|
||||
if (props.workspaceID && listed()) return true
|
||||
if (props.workspaceID) return x.workspaceID === props.workspaceID
|
||||
if (props.localOnly) return !x.workspaceID
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
|
||||
options={options()}
|
||||
skipFilter={!props.localOnly}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const deleted = await sdk.client.session
|
||||
.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
setToDelete(undefined)
|
||||
if (!deleted) {
|
||||
toast.show({
|
||||
message: "Failed to delete session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (props.workspaceID) {
|
||||
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
|
||||
return
|
||||
}
|
||||
sync.set(
|
||||
"session",
|
||||
sync.data.session.filter((session) => session.id !== option.value),
|
||||
)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
44
packages/opencode/src/cli/cmd/tui/context/keybind-plugin.ts
Normal file
44
packages/opencode/src/cli/cmd/tui/context/keybind-plugin.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export type PluginKeybindMap = Record<string, string>
|
||||
|
||||
type Base<Info> = {
|
||||
parse: (evt: ParsedKey) => Info
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
}
|
||||
|
||||
export type PluginKeybind<Info> = {
|
||||
readonly all: PluginKeybindMap
|
||||
get: (name: string) => string
|
||||
parse: (evt: ParsedKey) => Info
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
const txt = (value: unknown) => {
|
||||
if (typeof value !== "string") return
|
||||
if (!value.trim()) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPluginKeybind<Info>(
|
||||
base: Base<Info>,
|
||||
defaults: PluginKeybindMap,
|
||||
overrides?: Record<string, unknown>,
|
||||
): PluginKeybind<Info> {
|
||||
const all = Object.freeze(
|
||||
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||
) as PluginKeybindMap
|
||||
const get = (name: string) => all[name] ?? name
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return all
|
||||
},
|
||||
get,
|
||||
parse: (evt) => base.parse(evt),
|
||||
match: (name, evt) => base.match(get(name), evt),
|
||||
print: (name) => base.print(get(name)),
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createPluginKeybind, type PluginKeybindMap } from "./keybind-plugin"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
|
||||
@@ -80,21 +81,27 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: KeybindKey, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
match(key: string, evt: ParsedKey) {
|
||||
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||
if (!list.length) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const key of keybind) {
|
||||
if (Keybind.match(key, parsed)) {
|
||||
for (const item of list) {
|
||||
if (Keybind.match(item, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
print(key: KeybindKey) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
print(key: string) {
|
||||
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||
if (!first) return ""
|
||||
const result = Keybind.toString(first)
|
||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
||||
const text = Keybind.toString(first)
|
||||
const lead = keybinds().leader?.[0]
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
},
|
||||
create(defaults: PluginKeybindMap, overrides?: Record<string, unknown>) {
|
||||
return createPluginKeybind(result, defaults, overrides)
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
on: (handler: (event: Event) => void) => () => void
|
||||
setWorkspace?: (workspaceID?: string) => void
|
||||
}
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
@@ -17,13 +18,21 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
events?: EventSource
|
||||
}) => {
|
||||
const abort = new AbortController()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
})
|
||||
let workspaceID: string | undefined
|
||||
let sse: AbortController | undefined
|
||||
|
||||
function createSDK() {
|
||||
return createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
let sdk = createSDK()
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
@@ -61,41 +70,56 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
flush()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// If an event source is provided, use it instead of SSE
|
||||
function startSSE() {
|
||||
sse?.abort()
|
||||
const ctrl = new AbortController()
|
||||
sse = ctrl
|
||||
;(async () => {
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) flush()
|
||||
}
|
||||
})().catch(() => {})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (props.events) {
|
||||
const unsub = props.events.on(handleEvent)
|
||||
onCleanup(unsub)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to SSE
|
||||
while (true) {
|
||||
if (abort.signal.aborted) break
|
||||
const events = await sdk.event.subscribe(
|
||||
{},
|
||||
{
|
||||
signal: abort.signal,
|
||||
},
|
||||
)
|
||||
|
||||
for await (const event of events.stream) {
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
// Flush any remaining events
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) {
|
||||
flush()
|
||||
}
|
||||
} else {
|
||||
startSSE()
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
sse?.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
|
||||
return { client: sdk, event: emitter, url: props.url }
|
||||
return {
|
||||
get client() {
|
||||
return sdk
|
||||
},
|
||||
directory: props.directory,
|
||||
event: emitter,
|
||||
fetch: props.fetch ?? fetch,
|
||||
setWorkspace(next?: string) {
|
||||
if (workspaceID === next) return
|
||||
workspaceID = next
|
||||
sdk = createSDK()
|
||||
props.events?.setWorkspace?.(next)
|
||||
if (!props.events) startSSE()
|
||||
},
|
||||
url: props.url,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
path: Path
|
||||
workspaceList: Workspace[]
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
@@ -100,10 +102,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
formatter: [],
|
||||
vcs: undefined,
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
workspaceList: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
}
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
@@ -413,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||
syncWorkspaces(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -481,6 +491,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
fullSyncedSessions.add(sessionID)
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
bootstrap,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -42,6 +42,7 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
@@ -174,6 +175,56 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
carbonfox,
|
||||
}
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
mode: "dark" | "light"
|
||||
active: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<State>({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: "dark",
|
||||
active: "opencode",
|
||||
ready: false,
|
||||
})
|
||||
|
||||
function mergeThemes(themes: Record<string, ThemeJson>) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
for (const [name, theme] of Object.entries(themes)) {
|
||||
if (draft.themes[name]) continue
|
||||
draft.themes[name] = theme
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function allThemes() {
|
||||
return store.themes
|
||||
}
|
||||
|
||||
function isTheme(theme: unknown): theme is ThemeJson {
|
||||
if (!isRecord(theme)) return false
|
||||
if (!isRecord(theme.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasTheme(name: string) {
|
||||
if (!name) return false
|
||||
return allThemes()[name] !== undefined
|
||||
}
|
||||
|
||||
export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
mergeThemes({
|
||||
[name]: theme,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue): RGBA {
|
||||
@@ -282,12 +333,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
init: (props: { mode: "dark" | "light" }) => {
|
||||
const config = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: kv.get("theme_mode", props.mode),
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.mode = kv.get("theme_mode", props.mode)
|
||||
draft.active = (config.theme ?? kv.get("theme", "opencode")) as string
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const theme = config.theme
|
||||
@@ -295,55 +348,49 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
function init() {
|
||||
resolveSystemTheme()
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.active !== "system") {
|
||||
setStore("ready", true)
|
||||
}
|
||||
})
|
||||
Promise.allSettled([
|
||||
resolveSystemTheme(),
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
}),
|
||||
]).finally(() => {
|
||||
setStore("ready", true)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolveSystemTheme")
|
||||
renderer
|
||||
return renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
console.log(colors.palette)
|
||||
if (!colors.palette[0]) {
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
}),
|
||||
)
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, store.mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
if (store.active === "system") {
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderer = useRenderer()
|
||||
@@ -370,7 +417,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
return store.active
|
||||
},
|
||||
all() {
|
||||
return store.themes
|
||||
return allThemes()
|
||||
},
|
||||
has(name: string) {
|
||||
return hasTheme(name)
|
||||
},
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
@@ -382,8 +432,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
kv.set("theme_mode", mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
kv.set("theme", theme)
|
||||
return true
|
||||
},
|
||||
get ready() {
|
||||
return store.ready
|
||||
|
||||
323
packages/opencode/src/cli/cmd/tui/plugin.ts
Normal file
323
packages/opencode/src/cli/cmd/tui/plugin.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
type TuiPlugin as TuiPluginFn,
|
||||
type TuiPluginInit,
|
||||
type TuiPluginInput,
|
||||
type TuiTheme,
|
||||
type TuiSlotContext,
|
||||
type TuiSlotMap,
|
||||
type TuiSlots,
|
||||
type SlotMode,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import "@opentui/solid/preload"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
import { addTheme, hasTheme } from "./context/theme"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
type SlotProps<K extends keyof TuiSlotMap> = {
|
||||
name: K
|
||||
mode?: SlotMode
|
||||
children?: JSX.Element
|
||||
} & TuiSlotMap[K]
|
||||
|
||||
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
|
||||
type InitInput = Omit<TuiPluginInput<CliRenderer>, "slots">
|
||||
|
||||
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
|
||||
return null
|
||||
}
|
||||
|
||||
function isTuiSlotPlugin(value: unknown): value is SolidPlugin<TuiSlotMap, TuiSlotContext> {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.id !== "string") return false
|
||||
if (!isRecord(value.slots)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function getTuiSlotPlugin(value: unknown) {
|
||||
if (isTuiSlotPlugin(value)) return value
|
||||
if (!isRecord(value)) return
|
||||
if (!isTuiSlotPlugin(value.slots)) return
|
||||
return value.slots
|
||||
}
|
||||
|
||||
function isTuiPlugin(value: unknown): value is TuiPluginFn<CliRenderer> {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function getTuiPlugin(value: unknown) {
|
||||
if (!isRecord(value) || !("tui" in value)) return
|
||||
if (!isTuiPlugin(value.tui)) return
|
||||
return value.tui
|
||||
}
|
||||
|
||||
function isTheme(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
if (!isRecord(value.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function localDir(file: string) {
|
||||
const dir = path.dirname(file)
|
||||
if (path.basename(dir) === ".opencode") return path.join(dir, "themes")
|
||||
return path.join(dir, ".opencode", "themes")
|
||||
}
|
||||
|
||||
function scopeDir(pluginMeta: TuiConfig.PluginMeta) {
|
||||
if (pluginMeta.scope === "local") return localDir(pluginMeta.source)
|
||||
return path.join(Global.Path.config, "themes")
|
||||
}
|
||||
|
||||
function pluginRoot(spec: string, target: string) {
|
||||
if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec))
|
||||
if (target.startsWith("file://")) return path.dirname(fileURLToPath(target))
|
||||
return target
|
||||
}
|
||||
|
||||
function resolveThemePath(root: string, file: string) {
|
||||
if (file.startsWith("file://")) return fileURLToPath(file)
|
||||
if (path.isAbsolute(file)) return file
|
||||
return path.resolve(root, file)
|
||||
}
|
||||
|
||||
function themeName(file: string) {
|
||||
return path.basename(file, path.extname(file))
|
||||
}
|
||||
|
||||
function getPluginMeta(config: TuiConfig.Info, item: Config.PluginSpec) {
|
||||
const key = Config.getPluginName(item)
|
||||
const value = config.plugin_meta?.[key]
|
||||
if (!value) {
|
||||
throw new Error(`missing plugin metadata for ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function makeInstallFn(meta: TuiConfig.PluginMeta, root: string): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const src = resolveThemePath(root, file)
|
||||
const theme = themeName(src)
|
||||
if (hasTheme(theme)) return
|
||||
|
||||
const text = await Bun.file(src)
|
||||
.text()
|
||||
.catch((error) => {
|
||||
throw new Error(`failed to read theme at ${src}: ${error}`)
|
||||
})
|
||||
const data = JSON.parse(text)
|
||||
if (!isTheme(data)) {
|
||||
throw new Error(`invalid theme at ${src}`)
|
||||
}
|
||||
|
||||
const dest = path.join(scopeDir(meta), `${theme}.json`)
|
||||
if (!(await Filesystem.exists(dest))) {
|
||||
await Filesystem.write(dest, text)
|
||||
}
|
||||
|
||||
addTheme(theme, data)
|
||||
}
|
||||
}
|
||||
|
||||
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 prep = 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
|
||||
const meta = await PluginMeta.touch(spec, target).catch((error) => {
|
||||
log.warn("failed to track tui plugin", { path: spec, retry, error })
|
||||
})
|
||||
if (meta && meta.state !== "same") {
|
||||
log.info("tui plugin metadata updated", {
|
||||
path: spec,
|
||||
retry,
|
||||
state: meta.state,
|
||||
source: meta.entry.source,
|
||||
version: meta.entry.version,
|
||||
modified: meta.entry.modified,
|
||||
})
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const init: TuiPluginInit = meta
|
||||
? {
|
||||
state: meta.state,
|
||||
entry: meta.entry,
|
||||
}
|
||||
: {
|
||||
state: "first",
|
||||
entry: {
|
||||
name: spec,
|
||||
source: spec.startsWith("file://") ? "file" : "npm",
|
||||
spec,
|
||||
target,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: target,
|
||||
},
|
||||
}
|
||||
|
||||
const root = pluginRoot(spec, target)
|
||||
const install = makeInstallFn(getPluginMeta(config, item), root)
|
||||
const mod = await import(target).catch((error) => {
|
||||
log.error("failed to load tui plugin", { path: spec, retry, error })
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
mod,
|
||||
install,
|
||||
init,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = await Promise.all(plugins.map((item) => prep(item)))
|
||||
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
let load = loaded[i]
|
||||
if (!load) {
|
||||
const item = plugins[i]
|
||||
if (!item) continue
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (!spec.startsWith("file://")) continue
|
||||
await wait()
|
||||
load = await prep(item, true)
|
||||
}
|
||||
if (!load) continue
|
||||
|
||||
// Keep plugin execution sequential for deterministic side effects:
|
||||
// command registration order affects keybind/command precedence,
|
||||
// route registration is last-wins when ids collide,
|
||||
// and hook chains rely on stable plugin ordering.
|
||||
for (const [name, value] of uniqueModuleEntries(load.mod)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
log.warn("ignoring non-object tui plugin export", {
|
||||
path: load.spec,
|
||||
name,
|
||||
type: value === null ? "null" : typeof value,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const slotPlugin = getTuiSlotPlugin(value)
|
||||
if (slotPlugin) input.slots.register(slotPlugin)
|
||||
|
||||
const tuiPlugin = getTuiPlugin(value)
|
||||
if (!tuiPlugin) continue
|
||||
await tuiPlugin(
|
||||
{
|
||||
...input,
|
||||
api: {
|
||||
command: input.api.command,
|
||||
route: input.api.route,
|
||||
ui: input.api.ui,
|
||||
keybind: input.api.keybind,
|
||||
theme: Object.create(input.api.theme, {
|
||||
install: {
|
||||
value: load.install,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
Config.pluginOptions(load.item) ?? null,
|
||||
load.init,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await PluginMeta.persist().catch((error) => {
|
||||
log.warn("failed to persist tui plugin metadata", { error })
|
||||
})
|
||||
}
|
||||
},
|
||||
}).catch((error) => {
|
||||
log.error("failed to load tui plugins", { directory: dir, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Logo } from "../component/logo"
|
||||
@@ -14,6 +14,8 @@ import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPlugin } from "../plugin"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
@@ -56,8 +58,8 @@ export function Home() {
|
||||
])
|
||||
|
||||
const Hint = (
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
@@ -70,12 +72,13 @@ export function Home() {
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
let prompt: PromptRef
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
onMount(() => {
|
||||
if (once) return
|
||||
if (route.initialPrompt) {
|
||||
@@ -84,9 +87,21 @@ export function Home() {
|
||||
} else if (args.prompt) {
|
||||
prompt.set({ input: args.prompt, parts: [] })
|
||||
once = true
|
||||
prompt.submit()
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for sync and model store to be ready before auto-submitting --prompt
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.ready && local.model.ready,
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
if (!args.prompt) return
|
||||
if (prompt.current?.input !== args.prompt) return
|
||||
prompt.submit()
|
||||
},
|
||||
),
|
||||
)
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
@@ -97,7 +112,9 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<Logo />
|
||||
<TuiPlugin.Slot name="home_logo" mode="replace">
|
||||
<Logo />
|
||||
</TuiPlugin.Slot>
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.workspace()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{props.workspace()}
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
@@ -59,6 +71,14 @@ export function Header() {
|
||||
return result
|
||||
})
|
||||
|
||||
const workspace = createMemo(() => {
|
||||
const id = session()?.workspaceID
|
||||
if (!id) return "Workspace local"
|
||||
const info = sync.workspace.get(id)
|
||||
if (!info) return `Workspace ${id}`
|
||||
return `Workspace ${id} (${info.type})`
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const command = useCommandDialog()
|
||||
@@ -83,9 +103,19 @@ export function Header() {
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
|
||||
<box flexDirection="column">
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
)}
|
||||
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
@@ -124,7 +154,14 @@ export function Header() {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
|
||||
<Title session={session} />
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
|
||||
<box flexDirection="column">
|
||||
<Title session={session} />
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<Title session={session} />
|
||||
)}
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
@@ -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"
|
||||
@@ -182,6 +181,12 @@ export function Session() {
|
||||
return new CustomSpeedScroll(3)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (session()?.workspaceID) {
|
||||
sdk.setWorkspace(session()?.workspaceID)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
await sync.session
|
||||
.sync(route.sessionID)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
on: (handler) => client.on<Event>("event", handler),
|
||||
setWorkspace: (workspaceID) => {
|
||||
void client.call("setWorkspace", { workspaceID })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export function Dialog(
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
@@ -70,8 +71,10 @@ function init() {
|
||||
useKeyboard((evt) => {
|
||||
if (store.stack.length === 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
@@ -151,6 +154,7 @@ export function DialogProvider(props: ParentProps) {
|
||||
{props.children}
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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"
|
||||
@@ -34,23 +34,38 @@ export namespace Clipboard {
|
||||
if (os === "darwin") {
|
||||
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
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()
|
||||
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 },
|
||||
)
|
||||
const buffer = await Filesystem.readBytes(tmpfile)
|
||||
return { data: buffer.toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
} finally {
|
||||
await $`rm -f "${tmpfile}"`.nothrow().quiet()
|
||||
await fs.rm(tmpfile, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
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 $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
|
||||
if (base64) {
|
||||
const imageBuffer = Buffer.from(base64.trim(), "base64")
|
||||
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (base64.text) {
|
||||
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
|
||||
if (imageBuffer.length > 0) {
|
||||
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
@@ -58,13 +73,15 @@ export namespace Clipboard {
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
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 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 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" }
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +98,7 @@ export namespace Clipboard {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
|
||||
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const eventStream = {
|
||||
abort: undefined as AbortController | undefined,
|
||||
}
|
||||
|
||||
const startEventStream = (directory: string) => {
|
||||
const startEventStream = (input: { directory: string; workspaceID?: string }) => {
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
const abort = new AbortController()
|
||||
eventStream.abort = abort
|
||||
@@ -54,12 +54,13 @@ const startEventStream = (directory: string) => {
|
||||
const request = new Request(input, init)
|
||||
const auth = getAuthorizationHeader()
|
||||
if (auth) request.headers.set("Authorization", auth)
|
||||
return Server.App().fetch(request)
|
||||
return Server.Default().fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
directory,
|
||||
directory: input.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
fetch: fetchFn,
|
||||
signal,
|
||||
})
|
||||
@@ -95,7 +96,7 @@ const startEventStream = (directory: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
startEventStream(process.cwd())
|
||||
startEventStream({ directory: process.cwd() })
|
||||
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
@@ -109,7 +110,7 @@ export const rpc = {
|
||||
headers,
|
||||
body: input.body,
|
||||
})
|
||||
const response = await Server.App().fetch(request)
|
||||
const response = await Server.Default().fetch(request)
|
||||
const body = await response.text()
|
||||
return {
|
||||
status: response.status,
|
||||
@@ -135,6 +136,9 @@ export const rpc = {
|
||||
Config.global.reset()
|
||||
await Instance.disposeAll()
|
||||
},
|
||||
async setWorkspace(input: { workspaceID?: string }) {
|
||||
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
|
||||
@@ -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,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
|
||||
const cmd = cmds[method]
|
||||
if (cmd) {
|
||||
spinner.start(`Running ${cmd.join(" ")}...`)
|
||||
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")
|
||||
) {
|
||||
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")) {
|
||||
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(" ")}`)
|
||||
|
||||
@@ -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"])
|
||||
@@ -1245,19 +1279,7 @@ export namespace Config {
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (e) {
|
||||
try {
|
||||
// import.meta.resolve sometimes fails with newly created node_modules
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
}
|
||||
data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
|
||||
}
|
||||
}
|
||||
return data
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -15,10 +16,43 @@ export namespace TuiConfig {
|
||||
|
||||
export const Info = TuiInfo
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
export type PluginMeta = {
|
||||
scope: "global" | "local"
|
||||
source: string
|
||||
}
|
||||
|
||||
type PluginEntry = {
|
||||
item: Config.PluginSpec
|
||||
meta: PluginMeta
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
plugin_meta?: Record<string, PluginMeta>
|
||||
}
|
||||
|
||||
function pluginScope(file: string): PluginMeta["scope"] {
|
||||
if (Instance.containsPath(file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
function dedupePlugins(list: PluginEntry[]) {
|
||||
const seen = new Set<string>()
|
||||
const result: PluginEntry[] = []
|
||||
for (const item of list.toReversed()) {
|
||||
const name = Config.getPluginName(item.item)
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
result.push(item)
|
||||
}
|
||||
return result.toReversed()
|
||||
}
|
||||
|
||||
function mergeInfo(target: Info, source: Info): Info {
|
||||
return mergeDeep(target, source)
|
||||
const merged = mergeDeep(target, source)
|
||||
if (target.plugin && source.plugin) {
|
||||
merged.plugin = [...target.plugin, ...source.plugin]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function customPath() {
|
||||
@@ -39,37 +73,74 @@ export namespace TuiConfig {
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
|
||||
let result: Info = {}
|
||||
const pluginEntries: PluginEntry[] = []
|
||||
|
||||
const mergeFile = async (file: string) => {
|
||||
const data = await loadFile(file)
|
||||
result = mergeInfo(result, data)
|
||||
if (!data.plugin?.length) return
|
||||
const sourceScope = pluginScope(file)
|
||||
for (const item of data.plugin) {
|
||||
pluginEntries.push({
|
||||
item,
|
||||
meta: {
|
||||
scope: sourceScope,
|
||||
source: file,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(file)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
result = mergeInfo(result, await loadFile(custom))
|
||||
await mergeFile(custom)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(file)
|
||||
}
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const merged = dedupePlugins(pluginEntries)
|
||||
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
|
||||
result.plugin = merged.map((item) => item.item)
|
||||
result.plugin_meta = merged.length
|
||||
? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta]))
|
||||
: undefined
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (result.plugin?.length) {
|
||||
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 +148,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 +163,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 +189,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,6 +1,5 @@
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { Workspace } from "./workspace"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
@@ -38,7 +37,7 @@ async function routeRequest(req: Request) {
|
||||
|
||||
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
// Only available in development for now
|
||||
if (!Installation.isLocal()) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
return next()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -11,6 +10,7 @@ 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)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const diffOutput = (
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
|
||||
const changedFiles: Info[] = []
|
||||
|
||||
@@ -439,12 +439,14 @@ export namespace File {
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput =
|
||||
await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const untrackedOutput = (
|
||||
await git(
|
||||
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
@@ -465,12 +467,14 @@ 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)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
@@ -541,16 +545,14 @@ 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).quiet().nothrow().text()
|
||||
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)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ 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"
|
||||
@@ -338,7 +338,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,14 +354,16 @@ export namespace Ripgrep {
|
||||
args.push("--")
|
||||
args.push(input.pattern)
|
||||
|
||||
const command = args.join(" ")
|
||||
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
const result = await Process.text(args, {
|
||||
cwd: input.cwd,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 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,13 +88,10 @@ export namespace FileWatcher {
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const vcsDir = await $`git rev-parse --git-dir`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
.text()
|
||||
.then((x) => path.resolve(Instance.worktree, x.trim()))
|
||||
.catch(() => undefined)
|
||||
const result = await git(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
||||
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
||||
|
||||
@@ -14,10 +14,12 @@ export namespace Flag {
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
||||
@@ -57,6 +59,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
@@ -104,6 +107,17 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because tests and external tooling may set this env var at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
|
||||
get() {
|
||||
return process.env["OPENCODE_PLUGIN_META_FILE"]
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_CLIENT
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because some commands override the client at runtime
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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
|
||||
@@ -15,6 +16,38 @@ 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 = {
|
||||
@@ -65,31 +98,31 @@ export namespace Installation {
|
||||
const checks = [
|
||||
{
|
||||
name: "npm" as const,
|
||||
command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
|
||||
command: () => text(["npm", "list", "-g", "--depth=0"]),
|
||||
},
|
||||
{
|
||||
name: "yarn" as const,
|
||||
command: () => $`yarn global list`.throws(false).quiet().text(),
|
||||
command: () => text(["yarn", "global", "list"]),
|
||||
},
|
||||
{
|
||||
name: "pnpm" as const,
|
||||
command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
|
||||
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
|
||||
},
|
||||
{
|
||||
name: "bun" as const,
|
||||
command: () => $`bun pm ls -g`.throws(false).quiet().text(),
|
||||
command: () => text(["bun", "pm", "ls", "-g"]),
|
||||
},
|
||||
{
|
||||
name: "brew" as const,
|
||||
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
|
||||
command: () => text(["brew", "list", "--formula", "opencode"]),
|
||||
},
|
||||
{
|
||||
name: "scoop" as const,
|
||||
command: () => $`scoop list opencode`.throws(false).quiet().text(),
|
||||
command: () => text(["scoop", "list", "opencode"]),
|
||||
},
|
||||
{
|
||||
name: "choco" as const,
|
||||
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
|
||||
command: () => text(["choco", "list", "--limit-output", "opencode"]),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -121,61 +154,70 @@ export namespace Installation {
|
||||
)
|
||||
|
||||
async function getBrewFormula() {
|
||||
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
|
||||
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
|
||||
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
|
||||
if (coreFormula.includes("opencode")) return "opencode"
|
||||
return "opencode"
|
||||
}
|
||||
|
||||
export async function upgrade(method: Method, target: string) {
|
||||
let cmd
|
||||
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
|
||||
switch (method) {
|
||||
case "curl":
|
||||
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
|
||||
...process.env,
|
||||
VERSION: target,
|
||||
})
|
||||
result = await upgradeCurl(target)
|
||||
break
|
||||
case "npm":
|
||||
cmd = $`npm install -g opencode-ai@${target}`
|
||||
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||
break
|
||||
case "pnpm":
|
||||
cmd = $`pnpm install -g opencode-ai@${target}`
|
||||
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||
break
|
||||
case "bun":
|
||||
cmd = $`bun install -g opencode-ai@${target}`
|
||||
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||
break
|
||||
case "brew": {
|
||||
const formula = await getBrewFormula()
|
||||
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({
|
||||
const 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":
|
||||
cmd = $`echo Y | choco upgrade opencode --version=${target}`
|
||||
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
|
||||
break
|
||||
case "scoop":
|
||||
cmd = $`scoop install opencode@${target}`
|
||||
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
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")
|
||||
if (!result || result.code !== 0) {
|
||||
const stderr =
|
||||
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
|
||||
throw new UpgradeFailedError({
|
||||
stderr: stderr,
|
||||
})
|
||||
@@ -186,7 +228,7 @@ export namespace Installation {
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
})
|
||||
await $`${process.execPath} --version`.nothrow().quiet().text()
|
||||
await Process.text([process.execPath, "--version"], { nothrow: true })
|
||||
}
|
||||
|
||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
@@ -199,7 +241,7 @@ export namespace Installation {
|
||||
if (detectedMethod === "brew") {
|
||||
const formula = await getBrewFormula()
|
||||
if (formula.includes("/")) {
|
||||
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
|
||||
const infoJson = await text(["brew", "info", "--json=v2", formula])
|
||||
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}`)
|
||||
@@ -215,7 +257,7 @@ export namespace Installation {
|
||||
|
||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||
const registry = await iife(async () => {
|
||||
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
|
||||
const r = (await text(["npm", "config", "get", "registry"])).trim()
|
||||
const reg = r || "https://registry.npmjs.org"
|
||||
return reg.endsWith("/") ? reg.slice(0, -1) : reg
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -13,6 +12,7 @@ import { Flag } from "../flag/flag"
|
||||
import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -21,6 +21,8 @@ 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
|
||||
@@ -97,7 +99,7 @@ export namespace LSPServer {
|
||||
),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
log.info("typescript server", { tsserver })
|
||||
if (!tsserver) return
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
@@ -172,7 +174,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
|
||||
async spawn(root) {
|
||||
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
|
||||
const eslint = Module.resolve("eslint", Instance.directory)
|
||||
if (!eslint) return
|
||||
log.info("spawning eslint server")
|
||||
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
|
||||
@@ -205,8 +207,8 @@ export namespace LSPServer {
|
||||
await fs.rename(extractedPath, finalPath)
|
||||
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
await $`${npmCmd} install`.cwd(finalPath).quiet()
|
||||
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
|
||||
await Process.run([npmCmd, "install"], { cwd: finalPath })
|
||||
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
|
||||
|
||||
log.info("installed VS Code ESLint server", { serverPath })
|
||||
}
|
||||
@@ -340,7 +342,7 @@ export namespace LSPServer {
|
||||
let args = ["lsp-proxy", "--stdio"]
|
||||
|
||||
if (!bin) {
|
||||
const resolved = await Bun.resolve("biome", root).catch(() => undefined)
|
||||
const resolved = Module.resolve("biome", root)
|
||||
if (!resolved) return
|
||||
bin = BunProc.which()
|
||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
||||
@@ -602,10 +604,11 @@ export namespace LSPServer {
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
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 })
|
||||
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 })
|
||||
|
||||
log.info(`installed elixir-ls`, {
|
||||
path: elixirLsPath,
|
||||
@@ -706,7 +709,7 @@ export namespace LSPServer {
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -719,7 +722,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info(`installed zls`, { bin })
|
||||
@@ -831,11 +834,11 @@ export namespace LSPServer {
|
||||
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
|
||||
if (!which("xcrun")) return
|
||||
|
||||
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
|
||||
const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
|
||||
|
||||
if (lspLoc.exitCode !== 0) return
|
||||
if (lspLoc.code !== 0) return
|
||||
|
||||
const bin = lspLoc.text().trim()
|
||||
const bin = lspLoc.text.trim()
|
||||
|
||||
return {
|
||||
process: spawn(bin, {
|
||||
@@ -1010,7 +1013,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
}
|
||||
if (tar) {
|
||||
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
|
||||
}
|
||||
await fs.rm(archive, { force: true })
|
||||
|
||||
@@ -1021,7 +1024,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
|
||||
@@ -1082,7 +1085,7 @@ export namespace LSPServer {
|
||||
extensions: [".astro"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
if (!tsserver) {
|
||||
log.info("typescript not found, required for Astro language server")
|
||||
return
|
||||
@@ -1130,7 +1133,30 @@ export namespace LSPServer {
|
||||
|
||||
export const JDTLS: Info = {
|
||||
id: "jdtls",
|
||||
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
|
||||
root: async (file) => {
|
||||
// Without exclusions, NearestRoot defaults to instance directory so we can't
|
||||
// distinguish between a) no project found and b) project found at instance dir.
|
||||
// So we can't choose the root from (potential) monorepo markers first.
|
||||
// Look for potential subproject markers first while excluding potential monorepo markers.
|
||||
const settingsMarkers = ["settings.gradle", "settings.gradle.kts"]
|
||||
const gradleMarkers = ["gradlew", "gradlew.bat"]
|
||||
const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers)
|
||||
|
||||
const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([
|
||||
NearestRoot(
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
|
||||
exclusionsForMonorepos,
|
||||
)(file),
|
||||
NearestRoot(gradleMarkers, settingsMarkers)(file),
|
||||
NearestRoot(settingsMarkers)(file),
|
||||
])
|
||||
|
||||
// If projectRoot is undefined we know we are in a monorepo or no project at all.
|
||||
// So can safely fall through to the other roots
|
||||
if (projectRoot) return projectRoot
|
||||
if (wrapperRoot) return wrapperRoot
|
||||
if (settingsRoot) return settingsRoot
|
||||
},
|
||||
extensions: [".java"],
|
||||
async spawn(root) {
|
||||
const java = which("java")
|
||||
@@ -1138,13 +1164,10 @@ export namespace LSPServer {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
return
|
||||
}
|
||||
const javaMajorVersion = await $`java -version`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.then(({ stderr }) => {
|
||||
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
|
||||
return !m ? undefined : parseInt(m[1])
|
||||
})
|
||||
const javaMajorVersion = await run(["java", "-version"]).then((result) => {
|
||||
const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
|
||||
return !m ? undefined : parseInt(m[1])
|
||||
})
|
||||
if (javaMajorVersion == null || javaMajorVersion < 21) {
|
||||
log.error("JDTLS requires at least Java 21.")
|
||||
return
|
||||
@@ -1161,27 +1184,27 @@ export namespace LSPServer {
|
||||
const archiveName = "release.tar.gz"
|
||||
|
||||
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
|
||||
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() })
|
||||
const download = await fetch(releaseURL)
|
||||
if (!download.ok || !download.body) {
|
||||
log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
|
||||
return
|
||||
}
|
||||
await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
|
||||
|
||||
log.info("Extracting JDTLS archive")
|
||||
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() })
|
||||
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() })
|
||||
return
|
||||
}
|
||||
|
||||
await fs.rm(path.join(distPath, archiveName), { force: true })
|
||||
log.info("JDTLS download and extraction completed")
|
||||
}
|
||||
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
|
||||
.cwd(launcherDir)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.then(({ stdout }) => stdout.toString().trim())
|
||||
const jarFileName =
|
||||
(await fs.readdir(launcherDir).catch(() => []))
|
||||
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
|
||||
?.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}.`)
|
||||
@@ -1294,7 +1317,15 @@ export namespace LSPServer {
|
||||
|
||||
await fs.mkdir(distPath, { recursive: true })
|
||||
const archivePath = path.join(distPath, "kotlin-ls.zip")
|
||||
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
|
||||
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)
|
||||
const ok = await Archive.extractZip(archivePath, distPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
@@ -1304,7 +1335,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
await fs.rm(archivePath, { force: true })
|
||||
if (process.platform !== "win32") {
|
||||
await $`chmod +x ${launcherScript}`.quiet().nothrow()
|
||||
await fs.chmod(launcherScript, 0o755).catch(() => {})
|
||||
}
|
||||
log.info("Installed Kotlin Language Server", { path: launcherScript })
|
||||
}
|
||||
@@ -1468,10 +1499,9 @@ export namespace LSPServer {
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
|
||||
.quiet()
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
|
||||
.then((result) => result.code === 0)
|
||||
.catch((error: unknown) => {
|
||||
log.error("Failed to extract lua-language-server archive", { error })
|
||||
return false
|
||||
})
|
||||
@@ -1489,11 +1519,15 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
|
||||
log.error("Failed to set executable permission for lua-language-server binary", {
|
||||
error,
|
||||
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
|
||||
})
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
@@ -1707,7 +1741,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info(`installed terraform-ls`, { bin })
|
||||
@@ -1790,7 +1824,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
}
|
||||
if (ext === "tar.gz") {
|
||||
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -1803,7 +1837,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info("installed texlab", { bin })
|
||||
@@ -1995,7 +2029,7 @@ export namespace LSPServer {
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -2008,7 +2042,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info("installed tinymist", { bin })
|
||||
|
||||
@@ -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 {
|
||||
@@ -25,8 +25,7 @@ export namespace Plugin {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: Instance.directory,
|
||||
// @ts-ignore - fetch type incompatibility
|
||||
fetch: async (...args) => Server.App().fetch(...args),
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
const hooks: Hooks[] = []
|
||||
@@ -35,7 +34,9 @@ export namespace Plugin {
|
||||
project: Instance.project,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
serverUrl: Server.url(),
|
||||
get serverUrl(): URL {
|
||||
throw new Error("Server URL is no longer supported in plugins")
|
||||
},
|
||||
$: Bun.$,
|
||||
}
|
||||
|
||||
@@ -53,48 +54,83 @@ 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 resolvePlugin(spec: string) {
|
||||
const parsed = parsePluginSpecifier(spec)
|
||||
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
const prep = async (item: (typeof plugins)[number]) => {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
// ignore old codex plugin since it is supported first party now
|
||||
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) return
|
||||
log.info("loading plugin", { path: spec })
|
||||
const target = await resolvePlugin(spec)
|
||||
if (!target) return
|
||||
const mod = await import(target).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
mod,
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await Promise.all(plugins.map((item) => prep(item)))
|
||||
for (const load of loaded) {
|
||||
if (!load) continue
|
||||
|
||||
// Keep plugin execution sequential so hook registration and execution
|
||||
// order remains deterministic across plugin runs.
|
||||
// Prevent duplicate initialization when plugins export the same function
|
||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||
await import(plugin)
|
||||
.then(async (mod) => {
|
||||
const seen = new Set<PluginInstance>()
|
||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||
if (seen.has(fn)) continue
|
||||
seen.add(fn)
|
||||
hooks.push(await fn(input))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: plugin, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
// uniqueModuleEntries keeps only the first export for each shared value reference.
|
||||
await (async () => {
|
||||
for (const [, entry] of uniqueModuleEntries(load.mod)) {
|
||||
const server = getServerPlugin(entry)
|
||||
if (!server) throw new TypeError("Plugin export is not a function")
|
||||
hooks.push(await server(input, Config.pluginOptions(load.item)))
|
||||
}
|
||||
})().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
160
packages/opencode/src/plugin/meta.ts
Normal file
160
packages/opencode/src/plugin/meta.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
import { parsePluginSpecifier } from "./shared"
|
||||
|
||||
export namespace PluginMeta {
|
||||
type Source = "file" | "npm"
|
||||
|
||||
export type Entry = {
|
||||
name: string
|
||||
source: Source
|
||||
spec: string
|
||||
target: string
|
||||
requested?: string
|
||||
version?: string
|
||||
modified?: number
|
||||
first_time: number
|
||||
last_time: number
|
||||
time_changed: number
|
||||
load_count: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export type State = "first" | "updated" | "same"
|
||||
|
||||
type Store = Record<string, Entry>
|
||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
|
||||
|
||||
const cache = {
|
||||
ready: false,
|
||||
path: "",
|
||||
store: {} as Store,
|
||||
dirty: false,
|
||||
}
|
||||
|
||||
function storePath() {
|
||||
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
|
||||
}
|
||||
|
||||
function sourceKind(spec: string): Source {
|
||||
if (spec.startsWith("file://")) return "file"
|
||||
return "npm"
|
||||
}
|
||||
|
||||
function entryKey(spec: string) {
|
||||
if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}`
|
||||
return `npm:${parsePluginSpecifier(spec).pkg}`
|
||||
}
|
||||
|
||||
function entryName(spec: string) {
|
||||
if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name
|
||||
return parsePluginSpecifier(spec).pkg
|
||||
}
|
||||
|
||||
function fileTarget(spec: string, target: string) {
|
||||
if (spec.startsWith("file://")) return fileURLToPath(spec)
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
return
|
||||
}
|
||||
|
||||
function modifiedAt(file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
if (!stat) return
|
||||
const value = stat.mtimeMs
|
||||
return Math.floor(typeof value === "bigint" ? Number(value) : value)
|
||||
}
|
||||
|
||||
function resolvedTarget(target: string) {
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
return target
|
||||
}
|
||||
|
||||
async function npmVersion(target: string) {
|
||||
const resolved = resolvedTarget(target)
|
||||
const stat = Filesystem.stat(resolved)
|
||||
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
||||
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
||||
.then((item) => item.version)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function entryCore(spec: string, target: string): Promise<Core> {
|
||||
const source = sourceKind(spec)
|
||||
if (source === "file") {
|
||||
const file = fileTarget(spec, target)
|
||||
return {
|
||||
name: entryName(spec),
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
modified: file ? modifiedAt(file) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: entryName(spec),
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
requested: parsePluginSpecifier(spec).version,
|
||||
version: await npmVersion(target),
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprint(value: Core) {
|
||||
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
|
||||
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const next = storePath()
|
||||
if (cache.ready && cache.path === next) return
|
||||
cache.path = next
|
||||
cache.store = await Filesystem.readJson<Store>(next).catch(() => ({}) as Store)
|
||||
cache.dirty = false
|
||||
cache.ready = true
|
||||
}
|
||||
|
||||
export async function touch(spec: string, target: string): Promise<{ state: State; entry: Entry }> {
|
||||
await load()
|
||||
const now = Date.now()
|
||||
const id = entryKey(spec)
|
||||
const prev = cache.store[id]
|
||||
const core = await entryCore(spec, target)
|
||||
const entry: Entry = {
|
||||
...core,
|
||||
first_time: prev?.first_time ?? now,
|
||||
last_time: now,
|
||||
time_changed: prev?.time_changed ?? now,
|
||||
load_count: (prev?.load_count ?? 0) + 1,
|
||||
fingerprint: fingerprint(core),
|
||||
}
|
||||
|
||||
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
||||
if (state === "updated") entry.time_changed = now
|
||||
|
||||
cache.store[id] = entry
|
||||
cache.dirty = true
|
||||
return {
|
||||
state,
|
||||
entry,
|
||||
}
|
||||
}
|
||||
|
||||
export async function persist() {
|
||||
await load()
|
||||
if (!cache.dirty) return
|
||||
await Filesystem.writeJson(cache.path, cache.store)
|
||||
cache.dirty = false
|
||||
}
|
||||
|
||||
export async function list(): Promise<Store> {
|
||||
await load()
|
||||
return { ...cache.store }
|
||||
}
|
||||
}
|
||||
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 lastAt = spec.lastIndexOf("@")
|
||||
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
|
||||
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
|
||||
return { pkg, version }
|
||||
}
|
||||
|
||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||
if (spec.startsWith("file://")) return spec
|
||||
return BunProc.install(parsed.pkg, parsed.version)
|
||||
}
|
||||
|
||||
export function uniqueModuleEntries(mod: Record<string, unknown>) {
|
||||
const seen = new Set<unknown>()
|
||||
const entries: [string, unknown][] = []
|
||||
|
||||
for (const [name, entry] of Object.entries(mod)) {
|
||||
if (seen.has(entry)) continue
|
||||
seen.add(entry)
|
||||
entries.push([name, entry])
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
@@ -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() {
|
||||
return $`git rev-parse --abbrev-ref HEAD`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
.catch(() => undefined)
|
||||
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
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
|
||||
@@ -480,6 +480,7 @@ export namespace Provider {
|
||||
|
||||
const aiGatewayHeaders = {
|
||||
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -440,7 +440,9 @@ export namespace ProviderTransform {
|
||||
const copilotEfforts = iife(() => {
|
||||
if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3"))
|
||||
return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
|
||||
return WIDELY_SUPPORTED_EFFORTS
|
||||
const arr = [...WIDELY_SUPPORTED_EFFORTS]
|
||||
if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh")
|
||||
return arr
|
||||
})
|
||||
return Object.fromEntries(
|
||||
copilotEfforts.map((effort) => [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,8 @@ import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $, fileURLToPath, pathToFileURL } from "bun"
|
||||
import { $ } from "bun"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -9,12 +8,17 @@ 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",
|
||||
@@ -34,13 +38,13 @@ export namespace Snapshot {
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.exitCode,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
@@ -55,27 +59,27 @@ export namespace Snapshot {
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
if (await fs.mkdir(git, { recursive: true })) {
|
||||
await $`git init`
|
||||
.env({
|
||||
await Process.run(["git", "init"], {
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_DIR: git,
|
||||
GIT_WORK_TREE: Instance.worktree,
|
||||
})
|
||||
.quiet()
|
||||
.nothrow()
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
// Configure git to not convert line endings on Windows
|
||||
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()
|
||||
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 })
|
||||
log.info("initialized")
|
||||
}
|
||||
await add(git)
|
||||
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
.text()
|
||||
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
log.info("tracking", { hash, cwd: Instance.directory, git })
|
||||
return hash.trim()
|
||||
}
|
||||
@@ -89,19 +93,32 @@ export namespace Snapshot {
|
||||
export async function patch(hash: string): Promise<Patch> {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
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()
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
// If git diff fails, return empty patch
|
||||
if (result.exitCode !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.exitCode })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
|
||||
const files = result.text()
|
||||
const files = result.text
|
||||
return {
|
||||
hash,
|
||||
files: files
|
||||
@@ -116,20 +133,37 @@ export namespace Snapshot {
|
||||
export async function restore(snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = gitdir()
|
||||
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) {
|
||||
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
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr.toString(),
|
||||
stdout: checkout.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[]) {
|
||||
@@ -139,19 +173,37 @@ export namespace Snapshot {
|
||||
for (const file of item.files) {
|
||||
if (files.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
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 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 relativePath = path.relative(Instance.worktree, file)
|
||||
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()) {
|
||||
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()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", {
|
||||
file,
|
||||
})
|
||||
@@ -168,23 +220,36 @@ export namespace Snapshot {
|
||||
export async function diff(hash: string) {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
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()
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.exitCode,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.text().trim()
|
||||
return result.text.trim()
|
||||
}
|
||||
|
||||
export const FileDiff = z
|
||||
@@ -205,12 +270,24 @@ export namespace Snapshot {
|
||||
const result: FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
for (const line of statuses.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
@@ -220,26 +297,57 @@ export namespace Snapshot {
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
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()) {
|
||||
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,
|
||||
},
|
||||
)) {
|
||||
if (!line) continue
|
||||
const [additions, deletions, file] = line.split("\t")
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const before = isBinaryFile
|
||||
? ""
|
||||
: 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()
|
||||
: 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)
|
||||
const after = isBinaryFile
|
||||
? ""
|
||||
: 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()
|
||||
: 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)
|
||||
const added = isBinaryFile ? 0 : parseInt(additions)
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions)
|
||||
result.push({
|
||||
@@ -261,10 +369,22 @@ export namespace Snapshot {
|
||||
|
||||
async function add(git: string) {
|
||||
await syncExclude(git)
|
||||
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()
|
||||
await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["add", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function syncExclude(git: string) {
|
||||
@@ -281,11 +401,10 @@ export namespace Snapshot {
|
||||
}
|
||||
|
||||
async function excludes() {
|
||||
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
.text()
|
||||
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
}).then((x) => x.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,18 +49,15 @@ export namespace Storage {
|
||||
}
|
||||
if (!worktree) continue
|
||||
if (!(await Filesystem.isDir(worktree))) continue
|
||||
const [id] = await $`git rev-list --max-parents=0 --all`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(worktree)
|
||||
const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const [id] = result
|
||||
.text()
|
||||
.then((x) =>
|
||||
x
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted(),
|
||||
)
|
||||
.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,12 +116,7 @@ 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 $`realpath ${arg}`
|
||||
.cwd(cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
|
||||
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,9 +8,10 @@ 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 $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
|
||||
} else {
|
||||
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
|
||||
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
|
||||
return
|
||||
}
|
||||
|
||||
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ export namespace Process {
|
||||
stderr: Buffer
|
||||
}
|
||||
|
||||
export interface TextResult extends Result {
|
||||
text: string
|
||||
}
|
||||
|
||||
export class RunFailedError extends Error {
|
||||
readonly cmd: string[]
|
||||
readonly code: number
|
||||
@@ -114,13 +118,33 @@ export namespace Process {
|
||||
|
||||
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
||||
|
||||
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
||||
const out = {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
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)),
|
||||
}
|
||||
})
|
||||
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,4 +1,3 @@
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
@@ -11,6 +10,8 @@ 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"
|
||||
|
||||
@@ -248,14 +249,14 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
async function sweep(root: string) {
|
||||
const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root)
|
||||
const first = await git(["clean", "-ffdx"], { 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`.quiet().nothrow().cwd(root)
|
||||
return git(["clean", "-ffdx"], { cwd: root })
|
||||
}
|
||||
|
||||
async function canonical(input: string) {
|
||||
@@ -274,7 +275,9 @@ export namespace Worktree {
|
||||
if (await exists(directory)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (branchCheck.exitCode === 0) continue
|
||||
|
||||
return Info.parse({ name, branch, directory })
|
||||
@@ -285,9 +288,9 @@ export namespace Worktree {
|
||||
|
||||
async function runStartCommand(directory: string, cmd: string) {
|
||||
if (process.platform === "win32") {
|
||||
return $`cmd /c ${cmd}`.nothrow().cwd(directory)
|
||||
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
|
||||
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
|
||||
type StartKind = "project" | "worktree"
|
||||
@@ -297,7 +300,7 @@ export namespace Worktree {
|
||||
if (!text) return true
|
||||
|
||||
const ran = await runStartCommand(directory, text)
|
||||
if (ran.exitCode === 0) return true
|
||||
if (ran.code === 0) return true
|
||||
|
||||
log.error("worktree start command failed", {
|
||||
kind,
|
||||
@@ -344,10 +347,9 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (created.exitCode !== 0) {
|
||||
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
|
||||
}
|
||||
@@ -359,7 +361,7 @@ export namespace Worktree {
|
||||
|
||||
return () => {
|
||||
const start = async () => {
|
||||
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
|
||||
const populated = await git(["reset", "--hard"], { 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,10 +478,10 @@ export namespace Worktree {
|
||||
|
||||
const stop = async (target: string) => {
|
||||
if (!(await exists(target))) return
|
||||
await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target)
|
||||
await git(["fsmonitor--daemon", "stop"], { cwd: target })
|
||||
}
|
||||
|
||||
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
@@ -496,9 +498,11 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
await stop(entry.path)
|
||||
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const removed = await git(["worktree", "remove", "--force", entry.path], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (removed.exitCode !== 0) {
|
||||
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (next.exitCode !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
|
||||
@@ -515,7 +519,7 @@ export namespace Worktree {
|
||||
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
|
||||
if (deleted.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
|
||||
}
|
||||
@@ -535,7 +539,7 @@ export namespace Worktree {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
|
||||
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
@@ -568,7 +572,7 @@ export namespace Worktree {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
|
||||
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const remoteList = await git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
|
||||
}
|
||||
@@ -587,18 +591,19 @@ export namespace Worktree {
|
||||
: ""
|
||||
|
||||
const remoteHead = remote
|
||||
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
|
||||
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { 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`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
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 localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
|
||||
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
@@ -607,7 +612,7 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
if (remoteBranch) {
|
||||
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
|
||||
}
|
||||
@@ -619,7 +624,7 @@ export namespace Worktree {
|
||||
|
||||
const worktreePath = entry.path
|
||||
|
||||
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
|
||||
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
|
||||
if (resetToTarget.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
|
||||
}
|
||||
@@ -629,22 +634,26 @@ export namespace Worktree {
|
||||
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
|
||||
}
|
||||
|
||||
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
|
||||
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { 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`.quiet().nothrow().cwd(worktreePath)
|
||||
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
|
||||
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`.quiet().nothrow().cwd(worktreePath)
|
||||
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
|
||||
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`.quiet().nothrow().cwd(worktreePath)
|
||||
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||
if (status.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
|
||||
}
|
||||
|
||||
107
packages/opencode/test/cli/tui/keybind-plugin.test.ts
Normal file
107
packages/opencode/test/cli/tui/keybind-plugin.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
|
||||
|
||||
describe("createPluginKeybind", () => {
|
||||
const defaults = {
|
||||
open: "ctrl+o",
|
||||
close: "escape",
|
||||
}
|
||||
|
||||
test("uses defaults when overrides are missing", () => {
|
||||
const api = {
|
||||
parse: () => "parsed",
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults)
|
||||
|
||||
expect(bind.all).toEqual(defaults)
|
||||
expect(bind.get("open")).toBe("ctrl+o")
|
||||
expect(bind.get("close")).toBe("escape")
|
||||
})
|
||||
|
||||
test("applies valid overrides", () => {
|
||||
const api = {
|
||||
parse: () => "parsed",
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: "ctrl+alt+o",
|
||||
close: "q",
|
||||
})
|
||||
|
||||
expect(bind.all).toEqual({
|
||||
open: "ctrl+alt+o",
|
||||
close: "q",
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores invalid overrides", () => {
|
||||
const api = {
|
||||
parse: () => "parsed",
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: " ",
|
||||
close: 1,
|
||||
extra: "ctrl+x",
|
||||
})
|
||||
|
||||
expect(bind.all).toEqual(defaults)
|
||||
expect(bind.get("extra")).toBe("extra")
|
||||
})
|
||||
|
||||
test("delegates parse", () => {
|
||||
const evt = { name: "x" } as ParsedKey
|
||||
const api = {
|
||||
parse: (value: ParsedKey) => value,
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults)
|
||||
|
||||
expect(bind.parse(evt)).toBe(evt)
|
||||
})
|
||||
|
||||
test("resolves names for match", () => {
|
||||
const list: string[] = []
|
||||
const api = {
|
||||
parse: () => "parsed",
|
||||
match: (key: string) => {
|
||||
list.push(key)
|
||||
return true
|
||||
},
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: "ctrl+shift+o",
|
||||
})
|
||||
|
||||
bind.match("open", { name: "x" } as ParsedKey)
|
||||
bind.match("ctrl+k", { name: "x" } as ParsedKey)
|
||||
|
||||
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
|
||||
})
|
||||
|
||||
test("resolves names for print", () => {
|
||||
const list: string[] = []
|
||||
const api = {
|
||||
parse: () => "parsed",
|
||||
match: () => false,
|
||||
print: (key: string) => {
|
||||
list.push(key)
|
||||
return `print:${key}`
|
||||
},
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
close: "q",
|
||||
})
|
||||
|
||||
expect(bind.print("close")).toBe("print:q")
|
||||
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
|
||||
expect(list).toEqual(["q", "ctrl+p"])
|
||||
})
|
||||
})
|
||||
482
packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal file
482
packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { expect, mock, spyOn, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { Log } from "../../../src/util/log"
|
||||
import { Global } from "../../../src/global"
|
||||
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
|
||||
|
||||
mock.module("@opentui/solid/preload", () => ({}))
|
||||
mock.module("@opentui/solid", () => ({
|
||||
createSolidSlotRegistry: () => ({
|
||||
register: () => () => {},
|
||||
}),
|
||||
createSlot: () => () => null,
|
||||
useRenderer: () => ({
|
||||
getPalette: async () => ({ palette: [] as string[] }),
|
||||
clearPaletteCache: () => {},
|
||||
}),
|
||||
}))
|
||||
mock.module("@opentui/solid/jsx-runtime", () => ({
|
||||
Fragment: Symbol.for("Fragment"),
|
||||
jsx: () => null,
|
||||
jsxs: () => null,
|
||||
jsxDEV: () => null,
|
||||
}))
|
||||
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
||||
const { TuiPlugin } = await import("../../../src/cli/cmd/tui/plugin")
|
||||
const { PluginMeta } = await import("../../../src/plugin/meta")
|
||||
|
||||
async function waitForLog(text: string, timeout = 1000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
const file = Log.file()
|
||||
if (file) {
|
||||
const content = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (content.includes(text)) return content
|
||||
}
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
return Bun.file(Log.file())
|
||||
.text()
|
||||
.catch(() => "")
|
||||
}
|
||||
|
||||
test("loads plugin theme and keybind APIs with scoped theme installation", async () => {
|
||||
const stamp = Date.now()
|
||||
const globalConfigPath = path.join(Global.Path.config, "tui.json")
|
||||
const backup = await Bun.file(globalConfigPath)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const localPluginPath = path.join(dir, "local-plugin.ts")
|
||||
const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
|
||||
const globalPluginPath = path.join(dir, "global-plugin.ts")
|
||||
const localSpec = pathToFileURL(localPluginPath).href
|
||||
const preloadedSpec = pathToFileURL(preloadedPluginPath).href
|
||||
const globalSpec = pathToFileURL(globalPluginPath).href
|
||||
const localThemeFile = `local-theme-${stamp}.json`
|
||||
const globalThemeFile = `global-theme-${stamp}.json`
|
||||
const preloadedThemeFile = `preloaded-theme-${stamp}.json`
|
||||
const localThemeName = localThemeFile.replace(/\.json$/, "")
|
||||
const globalThemeName = globalThemeFile.replace(/\.json$/, "")
|
||||
const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
|
||||
const localThemePath = path.join(dir, localThemeFile)
|
||||
const globalThemePath = path.join(dir, globalThemeFile)
|
||||
const preloadedThemePath = path.join(dir, preloadedThemeFile)
|
||||
const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
|
||||
const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
|
||||
const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
|
||||
const fnMarker = path.join(dir, "function-called.txt")
|
||||
const localMarker = path.join(dir, "local-called.json")
|
||||
const globalMarker = path.join(dir, "global-called.json")
|
||||
const preloadedMarker = path.join(dir, "preloaded-called.json")
|
||||
const localConfigPath = path.join(dir, "tui.json")
|
||||
|
||||
await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
|
||||
await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
|
||||
await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
|
||||
await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
|
||||
|
||||
await Bun.write(
|
||||
localPluginPath,
|
||||
`export default async (_input, options) => {
|
||||
if (!options?.fn_marker) return
|
||||
await Bun.write(options.fn_marker, "called")
|
||||
}
|
||||
|
||||
export const object_plugin = {
|
||||
tui: async (input, options, init) => {
|
||||
if (!options?.marker) return
|
||||
const key = input.api.keybind.create(
|
||||
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
|
||||
options.keybinds,
|
||||
)
|
||||
const depth_before = input.api.ui.dialog.depth
|
||||
const open_before = input.api.ui.dialog.open
|
||||
const size_before = input.api.ui.dialog.size
|
||||
input.api.ui.dialog.setSize("large")
|
||||
const size_after = input.api.ui.dialog.size
|
||||
input.api.ui.dialog.replace(() => null)
|
||||
const depth_after = input.api.ui.dialog.depth
|
||||
const open_after = input.api.ui.dialog.open
|
||||
input.api.ui.dialog.clear()
|
||||
const open_clear = input.api.ui.dialog.open
|
||||
const before = input.api.theme.has(options.theme_name)
|
||||
const set_missing = input.api.theme.set(options.theme_name)
|
||||
await input.api.theme.install(options.theme_path)
|
||||
const after = input.api.theme.has(options.theme_name)
|
||||
const set_installed = input.api.theme.set(options.theme_name)
|
||||
const first = await Bun.file(options.dest).text()
|
||||
await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
|
||||
await input.api.theme.install(options.theme_path)
|
||||
const second = await Bun.file(options.dest).text()
|
||||
const init_state = init.state
|
||||
const init_source = init.entry.source
|
||||
const init_load_count = init.entry.load_count
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({
|
||||
before,
|
||||
set_missing,
|
||||
after,
|
||||
set_installed,
|
||||
selected: input.api.theme.selected,
|
||||
same: first === second,
|
||||
key_modal: key.get("modal"),
|
||||
key_close: key.get("close"),
|
||||
key_unknown: key.get("ctrl+k"),
|
||||
key_print: key.print("modal"),
|
||||
depth_before,
|
||||
open_before,
|
||||
size_before,
|
||||
size_after,
|
||||
depth_after,
|
||||
open_after,
|
||||
open_clear,
|
||||
init_state,
|
||||
init_source,
|
||||
init_load_count,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
preloadedPluginPath,
|
||||
`export default {
|
||||
tui: async (input, options, init) => {
|
||||
if (!options?.marker) return
|
||||
const before = input.api.theme.has(options.theme_name)
|
||||
await input.api.theme.install(options.theme_path)
|
||||
const after = input.api.theme.has(options.theme_name)
|
||||
const text = await Bun.file(options.dest).text()
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({
|
||||
before,
|
||||
after,
|
||||
text,
|
||||
init_state: init.state,
|
||||
init_source: init.entry.source,
|
||||
init_load_count: init.entry.load_count,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
globalPluginPath,
|
||||
`export default {
|
||||
tui: async (input, options, init) => {
|
||||
if (!options?.marker) return
|
||||
await input.api.theme.install(options.theme_path)
|
||||
const has = input.api.theme.has(options.theme_name)
|
||||
const set_installed = input.api.theme.set(options.theme_name)
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({
|
||||
has,
|
||||
set_installed,
|
||||
selected: input.api.theme.selected,
|
||||
init_state: init.state,
|
||||
init_source: init.entry.source,
|
||||
init_load_count: init.entry.load_count,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
globalConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [
|
||||
[globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
localConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [
|
||||
[
|
||||
localSpec,
|
||||
{
|
||||
fn_marker: fnMarker,
|
||||
marker: localMarker,
|
||||
source: localThemePath,
|
||||
dest: localDest,
|
||||
theme_path: `./${localThemeFile}`,
|
||||
theme_name: localThemeName,
|
||||
keybinds: {
|
||||
modal: "ctrl+alt+m",
|
||||
close: "q",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
preloadedSpec,
|
||||
{
|
||||
marker: preloadedMarker,
|
||||
dest: preloadedDest,
|
||||
theme_path: `./${preloadedThemeFile}`,
|
||||
theme_name: preloadedThemeName,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
localThemeFile,
|
||||
globalThemeFile,
|
||||
preloadedThemeFile,
|
||||
localThemeName,
|
||||
globalThemeName,
|
||||
preloadedThemeName,
|
||||
localDest,
|
||||
globalDest,
|
||||
preloadedDest,
|
||||
localPluginPath,
|
||||
globalPluginPath,
|
||||
preloadedPluginPath,
|
||||
localSpec,
|
||||
globalSpec,
|
||||
preloadedSpec,
|
||||
fnMarker,
|
||||
localMarker,
|
||||
globalMarker,
|
||||
preloadedMarker,
|
||||
}
|
||||
},
|
||||
})
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
if (!process.env.OPENCODE_PLUGIN_META_FILE) throw new Error("missing meta file")
|
||||
await PluginMeta.touch(tmp.extra.localSpec, tmp.extra.localSpec)
|
||||
await PluginMeta.touch(tmp.extra.globalSpec, tmp.extra.globalSpec)
|
||||
await PluginMeta.persist()
|
||||
await Bun.sleep(20)
|
||||
const text = await Bun.file(tmp.extra.globalPluginPath).text()
|
||||
await Bun.write(tmp.extra.globalPluginPath, `${text}\n`)
|
||||
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
let selected = "opencode"
|
||||
let depth = 0
|
||||
let size: "medium" | "large" = "medium"
|
||||
|
||||
const renderer = {
|
||||
...Object.create(null),
|
||||
once(this: CliRenderer) {
|
||||
return this
|
||||
},
|
||||
} satisfies CliRenderer
|
||||
const keybind = {
|
||||
parse: (evt: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) => ({
|
||||
name: evt.name ?? "",
|
||||
ctrl: evt.ctrl ?? false,
|
||||
meta: evt.meta ?? false,
|
||||
shift: evt.shift ?? false,
|
||||
super: evt.super,
|
||||
leader: false,
|
||||
}),
|
||||
match: () => false,
|
||||
print: (key: string) => `print:${key}`,
|
||||
}
|
||||
|
||||
try {
|
||||
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
|
||||
|
||||
await TuiPlugin.init({
|
||||
client: createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
}),
|
||||
event: {
|
||||
on: () => () => {},
|
||||
},
|
||||
renderer,
|
||||
api: {
|
||||
command: {
|
||||
register: () => {},
|
||||
trigger: () => {},
|
||||
},
|
||||
route: {
|
||||
register: () => () => {},
|
||||
navigate: () => {},
|
||||
get current() {
|
||||
return { name: "home" as const }
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
Dialog: () => null,
|
||||
DialogAlert: () => null,
|
||||
DialogConfirm: () => null,
|
||||
DialogPrompt: () => null,
|
||||
DialogSelect: () => null,
|
||||
toast: () => {},
|
||||
dialog: {
|
||||
replace: () => {
|
||||
depth = 1
|
||||
},
|
||||
clear: () => {
|
||||
depth = 0
|
||||
size = "medium"
|
||||
},
|
||||
setSize: (next) => {
|
||||
size = next
|
||||
},
|
||||
get size() {
|
||||
return size
|
||||
},
|
||||
get depth() {
|
||||
return depth
|
||||
},
|
||||
get open() {
|
||||
return depth > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
...keybind,
|
||||
create(defaults, overrides) {
|
||||
return createPluginKeybind(keybind, defaults, overrides)
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
return {}
|
||||
},
|
||||
get selected() {
|
||||
return selected
|
||||
},
|
||||
has(name) {
|
||||
return allThemes()[name] !== undefined
|
||||
},
|
||||
set(name) {
|
||||
if (!allThemes()[name]) return false
|
||||
selected = name
|
||||
return true
|
||||
},
|
||||
async install() {
|
||||
throw new Error("base theme.install should not run")
|
||||
},
|
||||
mode() {
|
||||
return "dark" as const
|
||||
},
|
||||
get ready() {
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const local = JSON.parse(await fs.readFile(tmp.extra.localMarker, "utf8"))
|
||||
expect(local.before).toBe(false)
|
||||
expect(local.set_missing).toBe(false)
|
||||
expect(local.after).toBe(true)
|
||||
expect(local.set_installed).toBe(true)
|
||||
expect(local.selected).toBe(tmp.extra.localThemeName)
|
||||
expect(local.same).toBe(true)
|
||||
expect(local.key_modal).toBe("ctrl+alt+m")
|
||||
expect(local.key_close).toBe("q")
|
||||
expect(local.key_unknown).toBe("ctrl+k")
|
||||
expect(local.key_print).toBe("print:ctrl+alt+m")
|
||||
expect(local.depth_before).toBe(0)
|
||||
expect(local.open_before).toBe(false)
|
||||
expect(local.size_before).toBe("medium")
|
||||
expect(local.size_after).toBe("large")
|
||||
expect(local.depth_after).toBe(1)
|
||||
expect(local.open_after).toBe(true)
|
||||
expect(local.open_clear).toBe(false)
|
||||
expect(local.init_state).toBe("same")
|
||||
expect(local.init_source).toBe("file")
|
||||
expect(local.init_load_count).toBe(2)
|
||||
|
||||
const global = JSON.parse(await fs.readFile(tmp.extra.globalMarker, "utf8"))
|
||||
expect(global.has).toBe(true)
|
||||
expect(global.set_installed).toBe(true)
|
||||
expect(global.selected).toBe(tmp.extra.globalThemeName)
|
||||
expect(global.init_state).toBe("updated")
|
||||
expect(global.init_source).toBe("file")
|
||||
expect(global.init_load_count).toBe(2)
|
||||
|
||||
const preloaded = JSON.parse(await fs.readFile(tmp.extra.preloadedMarker, "utf8"))
|
||||
expect(preloaded.before).toBe(true)
|
||||
expect(preloaded.after).toBe(true)
|
||||
expect(preloaded.text).toContain("#303030")
|
||||
expect(preloaded.text).not.toContain("#f0f0f0")
|
||||
expect(preloaded.init_state).toBe("first")
|
||||
expect(preloaded.init_source).toBe("file")
|
||||
expect(preloaded.init_load_count).toBe(1)
|
||||
|
||||
await expect(fs.readFile(tmp.extra.fnMarker, "utf8")).rejects.toThrow()
|
||||
|
||||
const localInstalled = await fs.readFile(tmp.extra.localDest, "utf8")
|
||||
expect(localInstalled).toContain("#101010")
|
||||
expect(localInstalled).not.toContain("#fefefe")
|
||||
|
||||
const globalInstalled = await fs.readFile(tmp.extra.globalDest, "utf8")
|
||||
expect(globalInstalled).toContain("#202020")
|
||||
|
||||
const preloadedInstalled = await fs.readFile(tmp.extra.preloadedDest, "utf8")
|
||||
expect(preloadedInstalled).toContain("#303030")
|
||||
expect(preloadedInstalled).not.toContain("#f0f0f0")
|
||||
|
||||
expect(
|
||||
await fs
|
||||
.stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
|
||||
const log = await waitForLog("ignoring non-object tui plugin export")
|
||||
expect(log).toContain("ignoring non-object tui plugin export")
|
||||
expect(log).toContain("name=default")
|
||||
expect(log).toContain("type=function")
|
||||
|
||||
const meta = JSON.parse(await fs.readFile(path.join(tmp.path, "plugin-meta.json"), "utf8")) as Record<
|
||||
string,
|
||||
{ name: string; load_count: number }
|
||||
>
|
||||
const rows = Object.values(meta)
|
||||
expect(rows.find((item) => item.name === "local-plugin")?.load_count).toBe(2)
|
||||
expect(rows.find((item) => item.name === "global-plugin")?.load_count).toBe(2)
|
||||
expect(rows.find((item) => item.name === "preloaded-plugin")?.load_count).toBe(1)
|
||||
} finally {
|
||||
cwd.mockRestore()
|
||||
if (backup === undefined) {
|
||||
await fs.rm(globalConfigPath, { force: true })
|
||||
} else {
|
||||
await Bun.write(globalConfigPath, backup)
|
||||
}
|
||||
await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
44
packages/opencode/test/cli/tui/theme-store.test.ts
Normal file
44
packages/opencode/test/cli/tui/theme-store.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
|
||||
mock.module("@opentui/solid/jsx-runtime", () => ({
|
||||
Fragment: Symbol.for("Fragment"),
|
||||
jsx: () => null,
|
||||
jsxs: () => null,
|
||||
jsxDEV: () => null,
|
||||
}))
|
||||
|
||||
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
||||
|
||||
test("addTheme writes into module theme store", () => {
|
||||
const name = `plugin-theme-${Date.now()}`
|
||||
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||
|
||||
expect(allThemes()[name]).toBeDefined()
|
||||
})
|
||||
|
||||
test("addTheme keeps first theme for duplicate names", () => {
|
||||
const name = `plugin-theme-keep-${Date.now()}`
|
||||
const one = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const two = structuredClone(DEFAULT_THEMES.opencode)
|
||||
;(one.theme as Record<string, unknown>).primary = "#101010"
|
||||
;(two.theme as Record<string, unknown>).primary = "#fefefe"
|
||||
|
||||
expect(addTheme(name, one)).toBe(true)
|
||||
expect(addTheme(name, two)).toBe(false)
|
||||
|
||||
expect(allThemes()[name]).toBeDefined()
|
||||
expect(allThemes()[name]!.theme.primary).toBe("#101010")
|
||||
})
|
||||
|
||||
test("addTheme ignores entries without a theme object", () => {
|
||||
const name = `plugin-theme-invalid-${Date.now()}`
|
||||
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
|
||||
expect(allThemes()[name]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("hasTheme checks theme presence", () => {
|
||||
const name = `plugin-theme-has-${Date.now()}`
|
||||
expect(hasTheme(name)).toBe(false)
|
||||
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||
expect(hasTheme(name)).toBe(true)
|
||||
})
|
||||
@@ -1727,7 +1727,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)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -458,9 +458,15 @@ test("applies file substitutions when first identical token is in a commented li
|
||||
test("loads managed tui config and gives it highest precedence", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2),
|
||||
)
|
||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
||||
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
|
||||
await Bun.write(
|
||||
path.join(managedConfigDir, "tui.json"),
|
||||
JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -469,6 +475,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("managed-theme")
|
||||
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
|
||||
expect(config.plugin_meta).toEqual({
|
||||
"shared-plugin": {
|
||||
scope: "global",
|
||||
source: path.join(managedConfigDir, "tui.json"),
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -508,3 +521,110 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("supports tuple plugin specs with options in tui.json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||
expect(config.plugin_meta).toEqual({
|
||||
"acme-plugin": {
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(Global.Path.config, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: [
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.plugin).toEqual([
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
])
|
||||
expect(config.plugin_meta).toEqual({
|
||||
"acme-plugin": {
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
"second-plugin": {
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks global and local plugin metadata in merged tui config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(Global.Path.config, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: ["global-plugin@1.0.0"],
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: ["local-plugin@2.0.0"],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
||||
expect(config.plugin_meta).toEqual({
|
||||
"global-plugin": {
|
||||
scope: "global",
|
||||
source: path.join(Global.Path.config, "tui.json"),
|
||||
},
|
||||
"local-plugin": {
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,12 +10,22 @@ import { Database } from "../../src/storage/db"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import * as adaptors from "../../src/control-plane/adaptors"
|
||||
import type { Adaptor } from "../../src/control-plane/types"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
|
||||
})
|
||||
|
||||
type State = {
|
||||
workspace?: "first" | "second"
|
||||
calls: Array<{ method: string; url: string; body?: string }>
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
87
packages/opencode/test/plugin/meta.test.ts
Normal file
87
packages/opencode/test/plugin/meta.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const { PluginMeta } = await import("../../src/plugin/meta")
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
})
|
||||
|
||||
describe("plugin.meta", () => {
|
||||
test("tracks file plugin loads and changes", async () => {
|
||||
await using tmp = await tmpdir<{ file: string }>({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
await Bun.write(file, "export default async () => ({})\n")
|
||||
return { file }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
|
||||
const file = process.env.OPENCODE_PLUGIN_META_FILE!
|
||||
const spec = pathToFileURL(tmp.extra.file).href
|
||||
|
||||
const one = await PluginMeta.touch(spec, spec)
|
||||
expect(one.state).toBe("first")
|
||||
expect(one.entry.source).toBe("file")
|
||||
expect(one.entry.modified).toBeDefined()
|
||||
|
||||
const two = await PluginMeta.touch(spec, spec)
|
||||
expect(two.state).toBe("same")
|
||||
expect(two.entry.load_count).toBe(2)
|
||||
|
||||
await Bun.sleep(20)
|
||||
await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n")
|
||||
|
||||
const three = await PluginMeta.touch(spec, spec)
|
||||
expect(three.state).toBe("updated")
|
||||
expect(three.entry.load_count).toBe(3)
|
||||
expect((three.entry.modified ?? 0) >= (one.entry.modified ?? 0)).toBe(true)
|
||||
|
||||
await expect(fs.readFile(file, "utf8")).rejects.toThrow()
|
||||
await PluginMeta.persist()
|
||||
|
||||
const all = await PluginMeta.list()
|
||||
expect(Object.values(all).some((item) => item.spec === spec && item.source === "file")).toBe(true)
|
||||
const saved = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, { spec: string; load_count: number }>
|
||||
expect(Object.values(saved).some((item) => item.spec === spec && item.load_count === 3)).toBe(true)
|
||||
})
|
||||
|
||||
test("tracks npm plugin versions", async () => {
|
||||
await using tmp = await tmpdir<{ mod: string; pkg: string }>({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "node_modules", "acme-plugin")
|
||||
const pkg = path.join(mod, "package.json")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
await Bun.write(pkg, JSON.stringify({ name: "acme-plugin", version: "1.0.0" }, null, 2))
|
||||
return { mod, pkg }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
|
||||
const file = process.env.OPENCODE_PLUGIN_META_FILE!
|
||||
|
||||
const one = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod)
|
||||
expect(one.state).toBe("first")
|
||||
expect(one.entry.source).toBe("npm")
|
||||
expect(one.entry.requested).toBe("latest")
|
||||
expect(one.entry.version).toBe("1.0.0")
|
||||
|
||||
await Bun.write(tmp.extra.pkg, JSON.stringify({ name: "acme-plugin", version: "1.1.0" }, null, 2))
|
||||
|
||||
const two = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod)
|
||||
expect(two.state).toBe("updated")
|
||||
expect(two.entry.version).toBe("1.1.0")
|
||||
expect(two.entry.load_count).toBe(2)
|
||||
await PluginMeta.persist()
|
||||
|
||||
const all = await PluginMeta.list()
|
||||
expect(Object.values(all).some((item) => item.name === "acme-plugin" && item.version === "1.1.0")).toBe(true)
|
||||
const saved = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, { name: string; version?: string }>
|
||||
expect(Object.values(saved).some((item) => item.name === "acme-plugin" && item.version === "1.1.0")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -198,6 +198,30 @@ test("GitLab Duo: config apiKey takes precedence over environment variable", asy
|
||||
})
|
||||
})
|
||||
|
||||
test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("GITLAB_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["gitlab"]).toBeDefined()
|
||||
expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("GitLab Duo: supports feature flags configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -2002,6 +2002,35 @@ describe("ProviderTransform.variants", () => {
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
|
||||
test("gpt-5.3-codex includes xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "gpt-5.3-codex",
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: "gpt-5.3-codex",
|
||||
url: "https://api.githubcopilot.com",
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
|
||||
test("gpt-5.4 includes xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "gpt-5.4",
|
||||
release_date: "2026-03-05",
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: "gpt-5.4",
|
||||
url: "https://api.githubcopilot.com",
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/cerebras", () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ afterEach(async () => {
|
||||
describe("project.initGit endpoint", () => {
|
||||
test("initializes git and reloads immediately", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
@@ -75,7 +75,7 @@ describe("project.initGit endpoint", () => {
|
||||
|
||||
test("does not reload when the project is already git", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
// #when
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -42,7 +42,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const nonExistentSessionID = "ses_nonexistent123"
|
||||
|
||||
// #when
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -63,7 +63,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const invalidSessionID = "invalid_session_id"
|
||||
|
||||
// #when
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
59
packages/opencode/test/util/module.test.ts
Normal file
59
packages/opencode/test/util/module.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("util.module", () => {
|
||||
test("resolves package subpaths from the provided dir", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.join(tmp.path, "proj")
|
||||
const file = path.join(root, "node_modules/typescript/lib/tsserver.js")
|
||||
await Filesystem.write(file, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(root, "node_modules/typescript/package.json"), { name: "typescript" })
|
||||
|
||||
expect(Module.resolve("typescript/lib/tsserver.js", root)).toBe(file)
|
||||
})
|
||||
|
||||
test("resolves packages through ancestor node_modules", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.join(tmp.path, "proj")
|
||||
const cwd = path.join(root, "apps/web")
|
||||
const file = path.join(root, "node_modules/eslint/lib/api.js")
|
||||
await Filesystem.write(file, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(root, "node_modules/eslint/package.json"), {
|
||||
name: "eslint",
|
||||
main: "lib/api.js",
|
||||
})
|
||||
await Filesystem.write(path.join(cwd, ".keep"), "")
|
||||
|
||||
expect(Module.resolve("eslint", cwd)).toBe(file)
|
||||
})
|
||||
|
||||
test("resolves relative to the provided dir", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a = path.join(tmp.path, "a")
|
||||
const b = path.join(tmp.path, "b")
|
||||
const left = path.join(a, "node_modules/biome/index.js")
|
||||
const right = path.join(b, "node_modules/biome/index.js")
|
||||
await Filesystem.write(left, "export {}\n")
|
||||
await Filesystem.write(right, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(a, "node_modules/biome/package.json"), {
|
||||
name: "biome",
|
||||
main: "index.js",
|
||||
})
|
||||
await Filesystem.writeJson(path.join(b, "node_modules/biome/package.json"), {
|
||||
name: "biome",
|
||||
main: "index.js",
|
||||
})
|
||||
|
||||
expect(Module.resolve("biome", a)).toBe(left)
|
||||
expect(Module.resolve("biome", b)).toBe(right)
|
||||
expect(Module.resolve("biome", a)).not.toBe(Module.resolve("biome", b))
|
||||
})
|
||||
|
||||
test("returns undefined when resolution fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
expect(Module.resolve("missing-package", tmp.path)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"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-20260307-536c401b",
|
||||
"@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
|
||||
|
||||
232
packages/plugin/src/tui.ts
Normal file
232
packages/plugin/src/tui.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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"
|
||||
|
||||
export type TuiRouteCurrent =
|
||||
| {
|
||||
name: "home"
|
||||
}
|
||||
| {
|
||||
name: "session"
|
||||
params: {
|
||||
sessionID: string
|
||||
initialPrompt?: unknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TuiRouteDefinition<Node = unknown> = {
|
||||
name: string
|
||||
render: (input: { params?: Record<string, unknown> }) => Node
|
||||
}
|
||||
|
||||
export type TuiCommand = {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: string
|
||||
suggested?: boolean
|
||||
hidden?: boolean
|
||||
enabled?: boolean
|
||||
slash?: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export type TuiKeybind = {
|
||||
name: string
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
super?: boolean
|
||||
leader: boolean
|
||||
}
|
||||
|
||||
export type TuiKeybindMap = Record<string, string>
|
||||
|
||||
export type TuiKeybindSet = {
|
||||
readonly all: TuiKeybindMap
|
||||
get: (name: string) => string
|
||||
parse: (evt: ParsedKey) => TuiKeybind
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
export type TuiDialogProps<Node = unknown> = {
|
||||
size?: "medium" | "large"
|
||||
onClose: () => void
|
||||
children?: Node
|
||||
}
|
||||
|
||||
export type TuiDialogStack<Node = unknown> = {
|
||||
replace: (render: () => Node, onClose?: () => void) => void
|
||||
clear: () => void
|
||||
setSize: (size: "medium" | "large") => void
|
||||
readonly size: "medium" | "large"
|
||||
readonly depth: number
|
||||
readonly open: boolean
|
||||
}
|
||||
|
||||
export type TuiDialogAlertProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogConfirmProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogPromptProps<Node = unknown> = {
|
||||
title: string
|
||||
description?: () => Node
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogSelectOption<Value = unknown, Node = unknown> = {
|
||||
title: string
|
||||
value: Value
|
||||
description?: string
|
||||
footer?: Node | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogSelectProps<Value = unknown, Node = unknown> = {
|
||||
title: string
|
||||
placeholder?: string
|
||||
options: TuiDialogSelectOption<Value, Node>[]
|
||||
flat?: boolean
|
||||
onMove?: (option: TuiDialogSelectOption<Value, Node>) => void
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: TuiDialogSelectOption<Value, Node>) => void
|
||||
skipFilter?: boolean
|
||||
current?: Value
|
||||
}
|
||||
|
||||
export type TuiToast = {
|
||||
variant?: "info" | "success" | "warning" | "error"
|
||||
title?: string
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type TuiTheme = {
|
||||
readonly current: Record<string, unknown>
|
||||
readonly selected: string
|
||||
has: (name: string) => boolean
|
||||
set: (name: string) => boolean
|
||||
install: (jsonPath: string) => Promise<void>
|
||||
mode: () => "dark" | "light"
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
||||
export type TuiApi<Node = unknown> = {
|
||||
command: {
|
||||
register: (cb: () => TuiCommand[]) => void
|
||||
trigger: (value: string) => void
|
||||
}
|
||||
route: {
|
||||
register: (routes: TuiRouteDefinition<Node>[]) => () => void
|
||||
navigate: (name: string, params?: Record<string, unknown>) => void
|
||||
readonly current: TuiRouteCurrent
|
||||
}
|
||||
ui: {
|
||||
Dialog: (props: TuiDialogProps<Node>) => Node
|
||||
DialogAlert: (props: TuiDialogAlertProps) => Node
|
||||
DialogConfirm: (props: TuiDialogConfirmProps) => Node
|
||||
DialogPrompt: (props: TuiDialogPromptProps<Node>) => Node
|
||||
DialogSelect: <Value = unknown>(props: TuiDialogSelectProps<Value, Node>) => Node
|
||||
toast: (input: TuiToast) => void
|
||||
dialog: TuiDialogStack<Node>
|
||||
}
|
||||
keybind: {
|
||||
parse: (evt: ParsedKey) => TuiKeybind
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
|
||||
}
|
||||
theme: TuiTheme
|
||||
}
|
||||
|
||||
export type 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 TuiPluginState = "first" | "updated" | "same"
|
||||
|
||||
export type TuiPluginMeta = {
|
||||
name: string
|
||||
source: "file" | "npm"
|
||||
spec: string
|
||||
target: string
|
||||
requested?: string
|
||||
version?: string
|
||||
modified?: number
|
||||
first_time: number
|
||||
last_time: number
|
||||
time_changed: number
|
||||
load_count: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export type TuiPluginInit = {
|
||||
state: TuiPluginState
|
||||
entry: TuiPluginMeta
|
||||
}
|
||||
|
||||
export type TuiPluginInput<Renderer = CliRenderer, Node = unknown> = {
|
||||
client: ReturnType<typeof createOpencodeClientV2>
|
||||
event: TuiEventBus
|
||||
renderer: Renderer
|
||||
slots: TuiSlots
|
||||
api: TuiApi<Node>
|
||||
}
|
||||
|
||||
export type TuiPlugin<Renderer = CliRenderer, Node = unknown> = (
|
||||
input: TuiPluginInput<Renderer, Node>,
|
||||
options: PluginOptions | null,
|
||||
init: TuiPluginInit,
|
||||
) => Promise<void>
|
||||
|
||||
export type TuiPluginModule<Renderer = CliRenderer, Node = unknown> = {
|
||||
server?: ServerPlugin
|
||||
tui?: TuiPlugin<Renderer, Node>
|
||||
slots?: TuiSlotPlugin
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "@opencode-ai/script",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:"
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "^7.5.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user