fix: stabilize v2 runtime behavior
This commit is contained in:
parent
ac2a78391f
commit
655adbf46e
35 changed files with 742 additions and 425 deletions
|
|
@ -135,6 +135,7 @@ const appBindingCommands = [
|
|||
|
||||
export type TuiInput = {
|
||||
client: OpencodeClient
|
||||
reload?: () => Promise<OpencodeClient>
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
|
|
@ -289,7 +290,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider client={input.client}>
|
||||
<SDKProvider client={input.client} reload={input.reload}>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<DataProvider>
|
||||
|
|
@ -360,6 +361,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
const keymap = useOpencodeKeymap()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const reload = sdk.reload
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
|
|
@ -744,6 +746,33 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
...(reload
|
||||
? [
|
||||
{
|
||||
name: "server.reload",
|
||||
title: "Reload server",
|
||||
slashName: "reload",
|
||||
slashAliases: ["restart"],
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "Reloading server...",
|
||||
duration: 30000,
|
||||
})
|
||||
await reload()
|
||||
.then(() =>
|
||||
toast.show({
|
||||
variant: "success",
|
||||
message: "Server reloaded",
|
||||
}),
|
||||
)
|
||||
.catch(toast.error)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "theme.switch",
|
||||
title: "Switch theme",
|
||||
|
|
@ -940,16 +969,16 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
|
||||
event.on("tui.command.execute", (evt, { workspace }) => {
|
||||
if (workspace !== project.workspace.current()) return
|
||||
keymap.dispatchCommand(evt.properties.command)
|
||||
keymap.dispatchCommand(evt.data.command)
|
||||
})
|
||||
|
||||
event.on("tui.toast.show", (evt, { workspace }) => {
|
||||
if (workspace !== project.workspace.current()) return
|
||||
toast.show({
|
||||
title: evt.properties.title,
|
||||
message: evt.properties.message,
|
||||
variant: evt.properties.variant,
|
||||
duration: evt.properties.duration,
|
||||
title: evt.data.title,
|
||||
message: evt.data.message,
|
||||
variant: evt.data.variant,
|
||||
duration: evt.data.duration,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -957,12 +986,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
if (workspace !== project.workspace.current()) return
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: evt.properties.sessionID,
|
||||
sessionID: evt.data.sessionID,
|
||||
})
|
||||
})
|
||||
|
||||
event.on("session.deleted", (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.data.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
variant: "info",
|
||||
|
|
@ -973,7 +1002,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
|
||||
event.on("session.error", (evt, { workspace }) => {
|
||||
if (workspace !== project.workspace.current()) return
|
||||
const error = evt.properties.error
|
||||
const error = evt.data.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = errorMessage(error)
|
||||
|
||||
|
|
@ -986,7 +1015,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
console.log("installation.update-available", evt)
|
||||
const version = evt.properties.version
|
||||
const version = evt.data.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
if (skipped && !isVersionGreater(version, skipped)) return
|
||||
|
|
@ -1085,8 +1114,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
<Show when={!startup.skipInitialLoading}>
|
||||
<StartupLoading ready={ready} />
|
||||
</Show>
|
||||
<Show when={data.connection.status() === "reconnecting"}>
|
||||
<Reconnecting attempt={data.connection.attempt()} error={data.connection.error()} />
|
||||
<Show when={sdk.connection.status() === "reconnecting"}>
|
||||
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -158,6 +158,12 @@ export function Prompt(props: PromptProps) {
|
|||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||
const activeSubagents = createMemo(() =>
|
||||
data.session
|
||||
.list()
|
||||
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
|
||||
.length,
|
||||
)
|
||||
const history = usePromptHistory()
|
||||
const stash = usePromptStash()
|
||||
const keymap = useOpencodeKeymap()
|
||||
|
|
@ -235,7 +241,7 @@ export function Prompt(props: PromptProps) {
|
|||
event.on("tui.prompt.append", (evt, { workspace }) => {
|
||||
if (workspace !== project.workspace.current()) return
|
||||
if (!input || input.isDestroyed) return
|
||||
input.insertText(evt.properties.text)
|
||||
input.insertText(evt.data.text)
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
|
|
@ -1534,6 +1540,13 @@ export function Prompt(props: PromptProps) {
|
|||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={activeSubagents()}>
|
||||
{(count) => (
|
||||
<Spinner color={theme.textMuted}>
|
||||
{count()} active subagent{count() === 1 ? "" : "s"}
|
||||
</Spinner>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
|
|
|
|||
|
|
@ -21,15 +21,10 @@ import type {
|
|||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
export type DataEvent = V2Event
|
||||
type DataEventMap = { [T in DataEvent["type"]]: Extract<DataEvent, { type: T }> }
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentV2Info[]
|
||||
command?: CommandV2Info[]
|
||||
|
|
@ -41,11 +36,6 @@ type LocationData = {
|
|||
}
|
||||
|
||||
type Data = {
|
||||
connection: {
|
||||
status: DataConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}
|
||||
session: {
|
||||
info: Record<string, SessionV2Info>
|
||||
status: Record<string, DataSessionStatus>
|
||||
|
|
@ -71,10 +61,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
name: "Data",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<Data>({
|
||||
connection: {
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
},
|
||||
session: {
|
||||
info: {},
|
||||
status: {},
|
||||
|
|
@ -89,7 +75,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const events = createGlobalEmitter<DataEventMap>()
|
||||
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
|
|
@ -151,6 +136,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
|
||||
function handleEvent(event: V2Event) {
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
void result.session.refresh(event.data.sessionID)
|
||||
break
|
||||
case "catalog.updated":
|
||||
void Promise.all([
|
||||
result.location.model.refresh(event.location),
|
||||
|
|
@ -477,21 +465,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
])
|
||||
break
|
||||
}
|
||||
events.emit(event.type, event)
|
||||
}
|
||||
|
||||
const result = {
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
on: sdk.event.on,
|
||||
listen: sdk.event.listen,
|
||||
connection: {
|
||||
status() {
|
||||
return store.connection.status
|
||||
return sdk.connection.status()
|
||||
},
|
||||
attempt() {
|
||||
return store.connection.attempt
|
||||
return sdk.connection.attempt()
|
||||
},
|
||||
error() {
|
||||
return store.connection.error
|
||||
return sdk.connection.error()
|
||||
},
|
||||
},
|
||||
session: {
|
||||
|
|
@ -687,52 +674,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
console.error("Failed to refresh default location data", failure.reason)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
const error = await (async () => {
|
||||
const events = await sdk.client.v2.event.subscribe({
|
||||
signal: controller.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
throwOnError: true,
|
||||
})
|
||||
const stream = events.stream[Symbol.asyncIterator]()
|
||||
const first = await stream.next()
|
||||
if (first.done) return new Error("Event stream disconnected")
|
||||
setStore("connection", { status: "connected", attempt: 0, error: undefined })
|
||||
handleEvent(first.value)
|
||||
await bootstrap()
|
||||
while (!controller.signal.aborted) {
|
||||
const event = await stream.next()
|
||||
if (event.done) return new Error("Event stream disconnected")
|
||||
handleEvent(event.value)
|
||||
}
|
||||
})().catch((error) => error)
|
||||
if (controller.signal.aborted) return
|
||||
setStore("connection", {
|
||||
status: "reconnecting",
|
||||
attempt: store.connection.status === "connected" ? 1 : store.connection.attempt + 1,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
await wait(250, controller.signal)
|
||||
}
|
||||
})()
|
||||
})
|
||||
onCleanup(
|
||||
sdk.event.listen(({ details }) => {
|
||||
handleEvent(details)
|
||||
if (details.type === "server.connected") void bootstrap()
|
||||
}),
|
||||
)
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,27 @@
|
|||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type EventMetadata = {
|
||||
directory: string
|
||||
directory: string | undefined
|
||||
workspace: string | undefined
|
||||
}
|
||||
|
||||
export function useEvent() {
|
||||
const sdk = useSDK()
|
||||
|
||||
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
|
||||
return sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "sync") {
|
||||
return
|
||||
}
|
||||
|
||||
handler(event.payload, { directory: event.directory, workspace: event.workspace })
|
||||
function subscribe(handler: (event: V2Event, metadata: EventMetadata) => void) {
|
||||
return sdk.event.listen(({ details }) => {
|
||||
if (details.type === "server.connected") return
|
||||
handler(details, { directory: details.location?.directory, workspace: details.location?.workspaceID })
|
||||
})
|
||||
}
|
||||
|
||||
function on<T extends Event["type"]>(
|
||||
function on<T extends V2Event["type"]>(
|
||||
type: T,
|
||||
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
|
||||
handler: (event: Extract<V2Event, { type: T }>, metadata: EventMetadata) => void,
|
||||
) {
|
||||
return subscribe((event: Event, metadata: EventMetadata) => {
|
||||
if (event.type !== type) return
|
||||
handler(event as Extract<Event, { type: T }>, metadata)
|
||||
return sdk.event.on(type, (event) => {
|
||||
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -474,7 +474,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
}
|
||||
|
||||
event.on("session.deleted", (evt) => {
|
||||
prune(evt.properties.info.id)
|
||||
prune(evt.data.info.id)
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { batch } from "solid-js"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
|
@ -67,11 +67,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
|||
})
|
||||
}
|
||||
|
||||
sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "workspace.status") {
|
||||
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
|
||||
}
|
||||
})
|
||||
onCleanup(
|
||||
sdk.event.on("workspace.status", (event) => {
|
||||
setStore("workspace", "status", event.data.workspaceID, event.data.status)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
data: store,
|
||||
|
|
|
|||
|
|
@ -1,89 +1,145 @@
|
|||
import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
|
||||
type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { client: OpencodeClient }) => {
|
||||
init: (props: { client: OpencodeClient; reload?: () => Promise<OpencodeClient> }) => {
|
||||
const abort = new AbortController()
|
||||
const handlers = new Set<(event: GlobalEvent) => void>()
|
||||
const emitter = {
|
||||
emit(_type: "event", event: GlobalEvent) {
|
||||
for (const handler of handlers) handler(event)
|
||||
},
|
||||
on(_type: "event", handler: (event: GlobalEvent) => void) {
|
||||
handlers.add(handler)
|
||||
return () => {
|
||||
handlers.delete(handler)
|
||||
}
|
||||
},
|
||||
}
|
||||
let client = props.client
|
||||
const events = createGlobalEmitter<SDKEventMap>()
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: SDKConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
let pending: Promise<void> | undefined
|
||||
|
||||
let queue: GlobalEvent[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
const retryDelay = 1000
|
||||
const maxRetryDelay = 30000
|
||||
|
||||
const flush = () => {
|
||||
if (queue.length === 0) return
|
||||
const events = queue
|
||||
queue = []
|
||||
timer = undefined
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) emitter.emit("event", event)
|
||||
function start() {
|
||||
stream?.abort()
|
||||
const controller = new AbortController()
|
||||
const current = client
|
||||
let connected!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
connected = resolve
|
||||
})
|
||||
}
|
||||
|
||||
const handleEvent = (event: GlobalEvent) => {
|
||||
queue.push(event)
|
||||
const elapsed = Date.now() - last
|
||||
if (timer) return
|
||||
if (elapsed < 16) {
|
||||
timer = setTimeout(flush, 16)
|
||||
return
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
stream = controller
|
||||
void (async () => {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted) {
|
||||
const events = await props.client.global.event({
|
||||
signal: abort.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
})
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) await props.client.sync.start().catch(() => {})
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (abort.signal.aborted) break
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) flush()
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const connection = new AbortController()
|
||||
const cancel = () => connection.abort(controller.signal.reason)
|
||||
const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
controller.signal.addEventListener("abort", cancel, { once: true })
|
||||
const error = await (async () => {
|
||||
const response = await current.v2.event.subscribe({
|
||||
signal: connection.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
throwOnError: true,
|
||||
})
|
||||
const iterator = response.stream[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (first.done)
|
||||
return connection.signal.reason instanceof Error
|
||||
? connection.signal.reason
|
||||
: new Error("Event stream disconnected")
|
||||
clearTimeout(timeout)
|
||||
attempt = 0
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
events.emit(first.value.type, first.value)
|
||||
connected()
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (event.done) return new Error("Event stream disconnected")
|
||||
events.emit(event.value.type, event.value)
|
||||
}
|
||||
})()
|
||||
.catch((error) => error)
|
||||
.finally(() => {
|
||||
clearTimeout(timeout)
|
||||
controller.signal.removeEventListener("abort", cancel)
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
attempt += 1
|
||||
if (abort.signal.aborted) break
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)),
|
||||
)
|
||||
setConnection({
|
||||
status: "reconnecting",
|
||||
attempt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
await wait(250, controller.signal)
|
||||
}
|
||||
})().catch(() => {})
|
||||
})
|
||||
})()
|
||||
return ready
|
||||
}
|
||||
|
||||
const reload = props.reload
|
||||
? () => {
|
||||
if (pending) return pending
|
||||
pending = Promise.resolve()
|
||||
.then(props.reload)
|
||||
.then(async (next) => {
|
||||
client = next
|
||||
if (!abort.signal.aborted) await start()
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
: undefined
|
||||
|
||||
onMount(() => void start())
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
handlers.clear()
|
||||
stream?.abort()
|
||||
events.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
client: props.client,
|
||||
event: emitter,
|
||||
get client() {
|
||||
return client
|
||||
},
|
||||
event: {
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
},
|
||||
connection: {
|
||||
status() {
|
||||
return connection.status
|
||||
},
|
||||
attempt() {
|
||||
return connection.attempt
|
||||
},
|
||||
error() {
|
||||
return connection.error
|
||||
},
|
||||
},
|
||||
reload,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const id = "internal:notifications"
|
||||
|
||||
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
|
||||
type SessionError = Extract<V2Event, { type: "session.error" }>["data"]["error"]
|
||||
|
||||
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
|
||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||
|
|
@ -33,27 +33,27 @@ const tui: TuiPlugin = async (api) => {
|
|||
const permissions = new Set<string>()
|
||||
|
||||
api.event.on("question.asked", (event) => {
|
||||
if (questions.has(event.properties.id)) return
|
||||
questions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Question needs input", "question")
|
||||
if (questions.has(event.data.id)) return
|
||||
questions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Question needs input", "question")
|
||||
})
|
||||
|
||||
api.event.on("question.replied", (event) => {
|
||||
questions.delete(event.properties.requestID)
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("question.rejected", (event) => {
|
||||
questions.delete(event.properties.requestID)
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.properties.id)) return
|
||||
permissions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Permission needs input", "permission")
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Permission needs input", "permission")
|
||||
})
|
||||
|
||||
api.event.on("permission.replied", (event) => {
|
||||
permissions.delete(event.properties.requestID)
|
||||
permissions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
const started = (sessionID: string) => {
|
||||
|
|
@ -74,18 +74,18 @@ const tui: TuiPlugin = async (api) => {
|
|||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
|
||||
api.event.on("session.next.prompted", (event) => started(event.properties.sessionID))
|
||||
api.event.on("session.next.shell.started", (event) => started(event.properties.sessionID))
|
||||
api.event.on("session.next.step.started", (event) => started(event.properties.sessionID))
|
||||
api.event.on("session.next.retried", (event) => started(event.properties.sessionID))
|
||||
api.event.on("session.next.compaction.started", (event) => started(event.properties.sessionID))
|
||||
api.event.on("session.next.shell.ended", (event) => ended(event.properties.sessionID))
|
||||
api.event.on("session.next.prompted", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.shell.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.step.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.retried", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.next.step.ended", (event) => {
|
||||
if (event.properties.finish === "tool-calls") return
|
||||
ended(event.properties.sessionID)
|
||||
if (event.data.finish === "tool-calls") return
|
||||
ended(event.data.sessionID)
|
||||
})
|
||||
api.event.on("session.next.step.failed", (event) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
const sessionID = event.data.sessionID
|
||||
if (!active.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, "Session error", "error")
|
||||
|
|
@ -93,11 +93,11 @@ const tui: TuiPlugin = async (api) => {
|
|||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (!active.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
|
||||
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1091,32 +1091,15 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
|||
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Show when={props.message.error}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.error}
|
||||
>
|
||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</>
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
<span style={{ fg: local.agent.color(props.message.agent) }}>{Locale.titlecase(props.message.agent)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1868,13 +1851,19 @@ function Shell(props: ToolProps) {
|
|||
const color = createMemo(() => (permission() ? theme.warning : theme.text))
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const command = createMemo(() => stringValue(props.input.command))
|
||||
const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? ""))
|
||||
const output = createMemo(() => {
|
||||
if (props.part.state.status === "pending") return ""
|
||||
const content = props.part.state.content[0]
|
||||
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
|
||||
})
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const maxLines = 10
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
||||
const input = createMemo(() => (command() ? `$ ${command()}` : ""))
|
||||
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
|
||||
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
|
||||
const limited = createMemo(() => {
|
||||
if (expanded() || !collapsed().overflow) return output()
|
||||
if (expanded() || !collapsed().overflow) return content()
|
||||
return collapsed().output
|
||||
})
|
||||
|
||||
|
|
@ -1882,13 +1871,21 @@ function Shell(props: ToolProps) {
|
|||
<BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}>
|
||||
<box gap={1}>
|
||||
<Show when={command()} fallback={<Spinner color={color()}>Writing command...</Spinner>}>
|
||||
<Show when={isRunning()} fallback={<text fg={permission() ? theme.warning : theme.textMuted}>$ {command()}</text>}>
|
||||
<Spinner color={color()}>{command()}</Spinner>
|
||||
<Show
|
||||
when={isRunning()}
|
||||
fallback={
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={color()}>
|
||||
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
|
||||
</Spinner>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={output()}>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
</Show>
|
||||
<Show when={collapsed().overflow}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
|
|
@ -152,8 +153,10 @@ export function reduceSessionRows(messages: SessionMessage[]) {
|
|||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID: part.id }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error)
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
|||
return { output, overflow: false }
|
||||
}
|
||||
|
||||
const preview = lines.slice(0, maxLines).join("\n")
|
||||
const visible = lines.slice(0, maxLines)
|
||||
if (lines.length > maxLines && visible.length > 0) visible[visible.length - 1] += "…"
|
||||
const preview = visible.join("\n")
|
||||
if (Array.from(preview).length > maxChars) {
|
||||
return {
|
||||
output:
|
||||
|
|
@ -15,5 +17,5 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
|||
}
|
||||
}
|
||||
|
||||
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
|
||||
return { output: preview, overflow: true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionRequest, QuestionRequest, Session, V2Event } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||
|
||||
async function setup() {
|
||||
const notifications: TuiAttentionNotifyInput[] = []
|
||||
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
|
||||
const handlers = new Map<V2Event["type"], ((event: V2Event) => void)[]>()
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
|
|
@ -33,9 +33,12 @@ async function setup() {
|
|||
},
|
||||
},
|
||||
event: {
|
||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
|
||||
on: <Type extends V2Event["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<V2Event, { type: Type }>) => void,
|
||||
) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: Event) => void
|
||||
const wrapped = handler as (event: V2Event) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
|
|
@ -58,7 +61,7 @@ async function setup() {
|
|||
|
||||
return {
|
||||
notifications,
|
||||
emit(event: Event) {
|
||||
emit(event: V2Event) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
},
|
||||
}
|
||||
|
|
@ -83,11 +86,11 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
|
|||
}
|
||||
}
|
||||
|
||||
function stepStarted(id: string, sessionID = "session"): Event {
|
||||
function stepStarted(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
|
|
@ -97,11 +100,11 @@ function stepStarted(id: string, sessionID = "session"): Event {
|
|||
}
|
||||
}
|
||||
|
||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
|
||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.ended",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
|
|
@ -112,11 +115,11 @@ function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
|
|||
}
|
||||
}
|
||||
|
||||
function stepFailed(id: string, sessionID = "session"): Event {
|
||||
function stepFailed(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.failed",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
|
|
@ -143,8 +146,8 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
||||
})
|
||||
|
|
@ -152,23 +155,23 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("dedupes pending questions and permissions until they are resolved", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "question.asked", data: question("question-1") })
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "question.replied",
|
||||
properties: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
data: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
})
|
||||
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-4", type: "question.asked", data: question("question-1") })
|
||||
|
||||
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") })
|
||||
harness.emit({
|
||||
id: "event-7",
|
||||
type: "permission.replied",
|
||||
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
data: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
})
|
||||
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
questionNotification,
|
||||
|
|
@ -198,7 +201,7 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
|
||||
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1", "subagent") })
|
||||
harness.emit(stepStarted("event-2", "subagent"))
|
||||
harness.emit(stepEnded("event-3", "subagent"))
|
||||
|
||||
|
|
@ -242,13 +245,13 @@ describe("internal notifications TUI plugin", () => {
|
|||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit(stepStarted("event-3", "timeout"))
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
|
|
|
|||
14
packages/tui/test/cli/tui/collapse-tool-output.test.ts
Normal file
14
packages/tui/test/cli/tui/collapse-tool-output.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { collapseToolOutput } from "../../../src/util/collapse-tool-output"
|
||||
|
||||
test("limits command input and output to the same line budget", () => {
|
||||
const command = Array.from({ length: 8 }, (_, index) => `command ${index + 1}`).join("\n")
|
||||
const output = Array.from({ length: 4 }, (_, index) => `output ${index + 1}`).join("\n")
|
||||
const collapsed = collapseToolOutput(`$ ${command}\n\n${output}`, 10, 1_000)
|
||||
|
||||
expect(collapsed.overflow).toBe(true)
|
||||
expect(collapsed.output.split("\n")).toHaveLength(10)
|
||||
expect(collapsed.output).toContain("$ command 1")
|
||||
expect(collapsed.output).toContain("command 8\n\noutput 1…")
|
||||
expect(collapsed.output).not.toContain("output 2")
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider } from "../../../src/context/project"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
|
|
@ -17,12 +17,8 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
|||
}
|
||||
}
|
||||
|
||||
function global(payload: Event): GlobalEvent {
|
||||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, payload: Event) {
|
||||
events.emit(global(payload))
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: V2Event) {
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
||||
test("refreshes resources into reactive getters", async () => {
|
||||
|
|
@ -205,7 +201,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
emitEvent(events, {
|
||||
id: "evt_step_started",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
assistantMessageID: "message-live",
|
||||
timestamp: 1,
|
||||
|
|
@ -218,7 +214,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
emitEvent(events, {
|
||||
id: "evt_step_ended",
|
||||
type: "session.next.step.ended",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
assistantMessageID: "message-live",
|
||||
timestamp: 2,
|
||||
|
|
@ -291,7 +287,7 @@ test("refreshes integrations after integration updates", async () => {
|
|||
expect(data.location.integration.list()).toEqual([])
|
||||
const before = { ...requests }
|
||||
|
||||
emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
|
||||
emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} })
|
||||
await wait(() => data.location.integration.list()?.length === 1)
|
||||
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
||||
|
|
@ -329,7 +325,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
|
|||
try {
|
||||
await wait(() => requests.model > 0 && requests.provider > 0)
|
||||
const before = { ...requests }
|
||||
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} })
|
||||
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} })
|
||||
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
@ -374,7 +370,7 @@ test("refreshes references after updates", async () => {
|
|||
try {
|
||||
await mounted
|
||||
await wait(() => requests === 1)
|
||||
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
|
||||
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} })
|
||||
await wait(() => data.location.reference.list()?.length === 1)
|
||||
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
|
||||
} finally {
|
||||
|
|
@ -409,7 +405,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_permission_asked_1",
|
||||
type: "permission.v2.asked",
|
||||
properties: {
|
||||
data: {
|
||||
id: "per_1",
|
||||
sessionID: "ses_1",
|
||||
action: "bash",
|
||||
|
|
@ -419,7 +415,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_permission_asked_2",
|
||||
type: "permission.v2.asked",
|
||||
properties: {
|
||||
data: {
|
||||
id: "per_2",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
|
|
@ -431,7 +427,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_permission_replied_1",
|
||||
type: "permission.v2.replied",
|
||||
properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
|
||||
data: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
|
||||
})
|
||||
await wait(() => data.session.permission.list("ses_1")?.length === 1)
|
||||
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2")
|
||||
|
|
@ -439,7 +435,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_permission_replied_2",
|
||||
type: "permission.v2.replied",
|
||||
properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
|
||||
data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
|
||||
})
|
||||
await wait(() => data.session.permission.list("ses_1")?.length === 0)
|
||||
} finally {
|
||||
|
|
@ -474,7 +470,7 @@ test("adds and dismisses question requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_question_asked_1",
|
||||
type: "question.v2.asked",
|
||||
properties: {
|
||||
data: {
|
||||
id: "que_1",
|
||||
sessionID: "ses_1",
|
||||
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
|
||||
|
|
@ -483,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_question_asked_2",
|
||||
type: "question.v2.asked",
|
||||
properties: {
|
||||
data: {
|
||||
id: "que_2",
|
||||
sessionID: "ses_1",
|
||||
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
|
||||
|
|
@ -494,7 +490,7 @@ test("adds and dismisses question requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_question_replied_1",
|
||||
type: "question.v2.replied",
|
||||
properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
|
||||
data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 1)
|
||||
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
|
||||
|
|
@ -502,7 +498,7 @@ test("adds and dismisses question requests from live events", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_question_rejected_2",
|
||||
type: "question.v2.rejected",
|
||||
properties: { sessionID: "ses_1", requestID: "que_2" },
|
||||
data: { sessionID: "ses_1", requestID: "que_2" },
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 0)
|
||||
} finally {
|
||||
|
|
@ -542,12 +538,12 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_model_1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_model_1",
|
||||
timestamp: 0,
|
||||
|
|
@ -557,7 +553,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_step_started_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 1,
|
||||
|
|
@ -568,7 +564,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_input_1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 2,
|
||||
|
|
@ -579,7 +575,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_called_1",
|
||||
type: "session.next.tool.called",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
|
|
@ -592,7 +588,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_failed_1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
|
|
@ -673,7 +669,7 @@ test("renders admitted prompts only after they become model-visible", async () =
|
|||
emitEvent(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 0,
|
||||
|
|
@ -686,7 +682,7 @@ test("renders admitted prompts only after they become model-visible", async () =
|
|||
emitEvent(events, {
|
||||
id: "evt_prompted_1",
|
||||
type: "session.next.prompted",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 0,
|
||||
|
|
@ -744,7 +740,7 @@ test("projects live context updates with their message ID", async () => {
|
|||
emitEvent(events, {
|
||||
id: "evt_context_1",
|
||||
type: "session.next.context.updated",
|
||||
properties: {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_context_1",
|
||||
timestamp: 1,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,39 @@ test("groups across empty assistant reasoning parts", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("completes exploration groups when another row follows", () => {
|
||||
const finished = assistant("assistant-2", [
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
])
|
||||
finished.finish = "stop"
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||
]),
|
||||
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||
finished,
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
},
|
||||
{ type: "assistant-footer", messageID: "assistant-2" },
|
||||
])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider, useProject } from "../../../src/context/project"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
import { SDKProvider, useSDK } from "../../../src/context/sdk"
|
||||
import { useEvent } from "../../../src/context/event"
|
||||
import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
|
@ -19,41 +19,40 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
|||
}
|
||||
}
|
||||
|
||||
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
|
||||
function event(payload: V2Event, input: { directory: string; project?: string; workspace?: string }): V2Event {
|
||||
return {
|
||||
directory: input.directory,
|
||||
project: input.project,
|
||||
workspace: input.workspace,
|
||||
payload,
|
||||
...payload,
|
||||
location: { directory: input.directory, workspaceID: input.workspace },
|
||||
}
|
||||
}
|
||||
|
||||
function vcs(branch: string): Event {
|
||||
function vcs(branch: string): V2Event {
|
||||
return {
|
||||
id: `evt_vcs_${branch}`,
|
||||
type: "vcs.branch.updated",
|
||||
properties: {
|
||||
data: {
|
||||
branch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function update(version: string): Event {
|
||||
function update(version: string): V2Event {
|
||||
return {
|
||||
id: `evt_update_${version}`,
|
||||
type: "installation.update-available",
|
||||
properties: {
|
||||
data: {
|
||||
version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
async function mount(reload?: () => Promise<OpencodeClient>) {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const seen: Event[] = []
|
||||
const seen: V2Event[] = []
|
||||
const workspaces: Array<string | undefined> = []
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let sdk!: ReturnType<typeof useSDK>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
|
|
@ -61,11 +60,12 @@ async function mount() {
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<SDKProvider client={createClient(calls.fetch)} reload={reload}>
|
||||
<ProjectProvider>
|
||||
<Probe
|
||||
onReady={async (ctx) => {
|
||||
project = ctx.project
|
||||
sdk = ctx.sdk
|
||||
await project.sync()
|
||||
done()
|
||||
}}
|
||||
|
|
@ -78,15 +78,16 @@ async function mount() {
|
|||
))
|
||||
|
||||
await ready
|
||||
return { app, emit: events.emit, project, seen, workspaces }
|
||||
return { app, emit: events.emit, project, sdk, seen, workspaces }
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
seen: Event[]
|
||||
seen: V2Event[]
|
||||
workspaces: Array<string | undefined>
|
||||
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
|
||||
onReady: (ctx: { project: ReturnType<typeof useProject>; sdk: ReturnType<typeof useSDK> }) => void
|
||||
}) {
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const event = useEvent()
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -94,7 +95,7 @@ function Probe(props: {
|
|||
props.seen.push(evt)
|
||||
props.workspaces.push(workspace)
|
||||
})
|
||||
props.onReady({ project })
|
||||
props.onReady({ project, sdk })
|
||||
})
|
||||
|
||||
return <box />
|
||||
|
|
@ -109,7 +110,7 @@ describe("useEvent", () => {
|
|||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("main")])
|
||||
expect(seen).toEqual([event(vcs("main"), { directory: "/tmp/other", workspace: "ws_a" })])
|
||||
expect(workspaces).toEqual(["ws_a"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
@ -125,7 +126,7 @@ describe("useEvent", () => {
|
|||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("ws")])
|
||||
expect(seen).toEqual([event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_b" })])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
@ -140,7 +141,54 @@ describe("useEvent", () => {
|
|||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([update("1.2.3")])
|
||||
expect(seen).toEqual([event(update("1.2.3"), { directory: "global" })])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("reloads the host and reconnects the event stream", async () => {
|
||||
let calls = 0
|
||||
const events = createEventStream()
|
||||
const replacement = createClient(createFetch(undefined, events).fetch)
|
||||
const { app, sdk, seen } = await mount(async () => {
|
||||
calls += 1
|
||||
return replacement
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => sdk.connection.status() === "connected")
|
||||
await sdk.reload?.()
|
||||
await wait(() => sdk.connection.status() === "connected")
|
||||
events.emit(event(vcs("reloaded"), { directory: "/tmp/reloaded" }))
|
||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded"))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(sdk.client).toBe(replacement)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the current event stream alive while the host reload is pending", async () => {
|
||||
let complete!: (client: OpencodeClient) => void
|
||||
const pending = new Promise<OpencodeClient>((resolve) => {
|
||||
complete = resolve
|
||||
})
|
||||
const replacementEvents = createEventStream()
|
||||
const replacement = createClient(createFetch(undefined, replacementEvents).fetch)
|
||||
const { app, emit, sdk, seen } = await mount(() => pending)
|
||||
|
||||
try {
|
||||
await wait(() => sdk.connection.status() === "connected")
|
||||
const reload = sdk.reload?.()
|
||||
emit(event(vcs("during-reload"), { directory: "/tmp/reload" }))
|
||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "during-reload"))
|
||||
|
||||
expect(sdk.connection.status()).toBe("connected")
|
||||
complete(replacement)
|
||||
await reload
|
||||
expect(sdk.client).toBe(replacement)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export const worktree = "/tmp/opencode"
|
||||
export const directory = `${worktree}/packages/tui`
|
||||
|
|
@ -13,12 +13,8 @@ export function json(data: unknown, init?: ResponseInit) {
|
|||
|
||||
export function createEventStream() {
|
||||
const encoder = new TextEncoder()
|
||||
const global = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const pending = {
|
||||
global: [] as Uint8Array[],
|
||||
v2: [] as Uint8Array[],
|
||||
}
|
||||
const pending: Uint8Array[] = []
|
||||
const response = (
|
||||
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
|
||||
queued: Uint8Array[],
|
||||
|
|
@ -54,24 +50,14 @@ export function createEventStream() {
|
|||
}
|
||||
|
||||
return {
|
||||
emit(event: GlobalEvent) {
|
||||
send(global, pending.global, event)
|
||||
if (!("properties" in event.payload)) return
|
||||
send(v2, pending.v2, {
|
||||
...event.payload,
|
||||
location: { directory: event.directory, workspaceID: event.workspace },
|
||||
data: event.payload.properties,
|
||||
})
|
||||
},
|
||||
global() {
|
||||
return response(global, pending.global)
|
||||
emit(event: V2Event) {
|
||||
send(v2, pending, event)
|
||||
},
|
||||
v2() {
|
||||
return response(v2, pending.v2, { id: "evt_connected", type: "server.connected", data: {} })
|
||||
return response(v2, pending, { id: "evt_connected", type: "server.connected", data: {} })
|
||||
},
|
||||
disconnect() {
|
||||
for (const controller of [...global, ...v2]) controller.close()
|
||||
global.clear()
|
||||
for (const controller of v2) controller.close()
|
||||
v2.clear()
|
||||
},
|
||||
}
|
||||
|
|
@ -86,7 +72,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
if (url.pathname === "/session") session.push(url)
|
||||
const overridden = await override?.(url)
|
||||
if (overridden) return overridden
|
||||
if (url.pathname === "/global/event" && events) return events.global()
|
||||
if (url.pathname === "/api/event" && events) return events.v2()
|
||||
|
||||
if (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue