Prepare TUI lifecycle for scenario tests (#28258)
This commit is contained in:
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|||||||
import * as Clipboard from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import * as Selection from "@tui/util/selection"
|
import * as Selection from "@tui/util/selection"
|
||||||
import * as TuiAudio from "@tui/util/audio"
|
import * as TuiAudio from "@tui/util/audio"
|
||||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import {
|
import {
|
||||||
Switch,
|
Switch,
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
on,
|
on,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||||
@@ -51,7 +51,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
|
|||||||
import { DialogAlert } from "./ui/dialog-alert"
|
import { DialogAlert } from "./ui/dialog-alert"
|
||||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||||
import { ToastProvider, useToast } from "./ui/toast"
|
import { ToastProvider, useToast } from "./ui/toast"
|
||||||
import { ExitProvider, useExit } from "./context/exit"
|
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
|
||||||
import { Session as SessionApi } from "@/session/session"
|
import { Session as SessionApi } from "@/session/session"
|
||||||
import { TuiEvent } from "./event"
|
import { TuiEvent } from "./event"
|
||||||
import { KVProvider, useKV } from "./context/kv"
|
import { KVProvider, useKV } from "./context/kv"
|
||||||
@@ -123,7 +123,7 @@ const appBindingCommands = [
|
|||||||
"app.toggle.session_directory_filter",
|
"app.toggle.session_directory_filter",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -146,6 +146,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createTuiRenderer(config: TuiConfig.Resolved) {
|
||||||
|
return createCliRenderer(tuiRendererConfig(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiHandle = {
|
||||||
|
ready: Promise<void>
|
||||||
|
done: Promise<void>
|
||||||
|
exit: Exit
|
||||||
|
}
|
||||||
|
|
||||||
|
type TuiInput = {
|
||||||
|
url: string
|
||||||
|
args: Args
|
||||||
|
config: TuiConfig.Resolved
|
||||||
|
renderer: CliRenderer
|
||||||
|
onSnapshot?: () => Promise<string[]>
|
||||||
|
directory?: string
|
||||||
|
fetch?: typeof fetch
|
||||||
|
headers?: RequestInit["headers"]
|
||||||
|
events?: EventSource
|
||||||
|
}
|
||||||
|
|
||||||
|
type TuiLifecycle = {
|
||||||
|
exit: Exit
|
||||||
|
exited: Promise<void>
|
||||||
|
fail(error: unknown): Promise<never>
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown) {
|
||||||
const formatted = FormatError(error)
|
const formatted = FormatError(error)
|
||||||
if (formatted !== undefined) return formatted
|
if (formatted !== undefined) return formatted
|
||||||
@@ -163,105 +191,175 @@ function errorMessage(error: unknown) {
|
|||||||
return FormatUnknownError(error)
|
return FormatUnknownError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tui(input: {
|
export function tui(input: TuiInput): TuiHandle {
|
||||||
url: string
|
const unguard = win32InstallCtrlCGuard()
|
||||||
args: Args
|
win32DisableProcessedInput()
|
||||||
config: TuiConfig.Resolved
|
|
||||||
onSnapshot?: () => Promise<string[]>
|
|
||||||
directory?: string
|
|
||||||
fetch?: typeof fetch
|
|
||||||
headers?: RequestInit["headers"]
|
|
||||||
events?: EventSource
|
|
||||||
}) {
|
|
||||||
// promise to prevent immediate exit
|
|
||||||
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
|
|
||||||
return new Promise<void>(async (resolve) => {
|
|
||||||
const unguard = win32InstallCtrlCGuard()
|
|
||||||
win32DisableProcessedInput()
|
|
||||||
|
|
||||||
const onExit = async () => {
|
const renderer = input.renderer
|
||||||
unguard?.()
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
resolve()
|
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||||
}
|
const lifecycle = createTuiLifecycle({
|
||||||
const onBeforeExit = async () => {
|
renderer,
|
||||||
offKeymap()
|
unguard,
|
||||||
|
cleanup: async () => {
|
||||||
|
unregisterKeymap()
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
TuiAudio.dispose()
|
TuiAudio.dispose()
|
||||||
}
|
},
|
||||||
|
|
||||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
|
||||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
|
||||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
|
||||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
|
||||||
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
|
||||||
|
|
||||||
await render(() => {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary
|
|
||||||
fallback={(error, reset) => (
|
|
||||||
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<ArgsProvider {...input.args}>
|
|
||||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
|
||||||
<KVProvider>
|
|
||||||
<ToastProvider>
|
|
||||||
<RouteProvider
|
|
||||||
initialRoute={
|
|
||||||
input.args.continue
|
|
||||||
? {
|
|
||||||
type: "session",
|
|
||||||
sessionID: "dummy",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TuiConfigProvider config={input.config}>
|
|
||||||
<SDKProvider
|
|
||||||
url={input.url}
|
|
||||||
directory={input.directory}
|
|
||||||
fetch={input.fetch}
|
|
||||||
headers={input.headers}
|
|
||||||
events={input.events}
|
|
||||||
>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<ThemeProvider mode={mode}>
|
|
||||||
<LocalProvider>
|
|
||||||
<PromptStashProvider>
|
|
||||||
<DialogProvider>
|
|
||||||
<FrecencyProvider>
|
|
||||||
<PromptHistoryProvider>
|
|
||||||
<PromptRefProvider>
|
|
||||||
<EditorContextProvider>
|
|
||||||
<App onSnapshot={input.onSnapshot} />
|
|
||||||
</EditorContextProvider>
|
|
||||||
</PromptRefProvider>
|
|
||||||
</PromptHistoryProvider>
|
|
||||||
</FrecencyProvider>
|
|
||||||
</DialogProvider>
|
|
||||||
</PromptStashProvider>
|
|
||||||
</LocalProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</SyncProviderV2>
|
|
||||||
</SyncProvider>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TuiConfigProvider>
|
|
||||||
</RouteProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
</KVProvider>
|
|
||||||
</ExitProvider>
|
|
||||||
</ArgsProvider>
|
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
)
|
|
||||||
}, renderer)
|
|
||||||
})
|
})
|
||||||
|
const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
|
||||||
|
const done = waitUntilDone(ready, lifecycle.exited)
|
||||||
|
|
||||||
|
return { ready, done, exit: lifecycle.exit }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; exit: Exit }) {
|
||||||
|
const renderer = input.renderer
|
||||||
|
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||||
|
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||||
|
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||||
|
if (renderer.isDestroyed) return
|
||||||
|
|
||||||
|
await render(() => {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} exit={input.exit} mode={mode} />}
|
||||||
|
>
|
||||||
|
<OpencodeKeymapProvider keymap={input.keymap}>
|
||||||
|
<ArgsProvider {...input.args}>
|
||||||
|
<ExitProvider exit={input.exit}>
|
||||||
|
<KVProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<RouteProvider
|
||||||
|
initialRoute={
|
||||||
|
input.args.continue
|
||||||
|
? {
|
||||||
|
type: "session",
|
||||||
|
sessionID: "dummy",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TuiConfigProvider config={input.config}>
|
||||||
|
<SDKProvider
|
||||||
|
url={input.url}
|
||||||
|
directory={input.directory}
|
||||||
|
fetch={input.fetch}
|
||||||
|
headers={input.headers}
|
||||||
|
events={input.events}
|
||||||
|
>
|
||||||
|
<ProjectProvider>
|
||||||
|
<SyncProvider>
|
||||||
|
<SyncProviderV2>
|
||||||
|
<ThemeProvider mode={mode}>
|
||||||
|
<LocalProvider>
|
||||||
|
<PromptStashProvider>
|
||||||
|
<DialogProvider>
|
||||||
|
<FrecencyProvider>
|
||||||
|
<PromptHistoryProvider>
|
||||||
|
<PromptRefProvider>
|
||||||
|
<EditorContextProvider>
|
||||||
|
<App onSnapshot={input.onSnapshot} />
|
||||||
|
</EditorContextProvider>
|
||||||
|
</PromptRefProvider>
|
||||||
|
</PromptHistoryProvider>
|
||||||
|
</FrecencyProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</PromptStashProvider>
|
||||||
|
</LocalProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SyncProviderV2>
|
||||||
|
</SyncProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TuiConfigProvider>
|
||||||
|
</RouteProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</KVProvider>
|
||||||
|
</ExitProvider>
|
||||||
|
</ArgsProvider>
|
||||||
|
</OpencodeKeymapProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}, renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTuiLifecycle(input: {
|
||||||
|
renderer: CliRenderer
|
||||||
|
unguard?: () => void
|
||||||
|
cleanup: () => Promise<void>
|
||||||
|
}): TuiLifecycle {
|
||||||
|
let resolveExited!: () => void
|
||||||
|
const exited = new Promise<void>((resolve) => {
|
||||||
|
resolveExited = resolve
|
||||||
|
})
|
||||||
|
let exitCompleted = false
|
||||||
|
let exiting = false
|
||||||
|
let cleanupTask: Promise<void> | undefined
|
||||||
|
|
||||||
|
const completeExit = () => {
|
||||||
|
if (exitCompleted) return
|
||||||
|
exitCompleted = true
|
||||||
|
resolveExited()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
cleanupTask ??= (async () => {
|
||||||
|
process.off("SIGHUP", onSighup)
|
||||||
|
try {
|
||||||
|
await input.cleanup()
|
||||||
|
} finally {
|
||||||
|
input.unguard?.()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return cleanupTask
|
||||||
|
}
|
||||||
|
|
||||||
|
const exit = createExit(async (reason, message) => {
|
||||||
|
exiting = true
|
||||||
|
await cleanup()
|
||||||
|
if (!input.renderer.isDestroyed) {
|
||||||
|
input.renderer.setTerminalTitle("")
|
||||||
|
input.renderer.destroy()
|
||||||
|
}
|
||||||
|
win32FlushInputBuffer()
|
||||||
|
if (reason) {
|
||||||
|
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||||
|
if (formatted) process.stderr.write(formatted + "\n")
|
||||||
|
}
|
||||||
|
const text = message()
|
||||||
|
if (text) process.stdout.write(text + "\n")
|
||||||
|
completeExit()
|
||||||
|
})
|
||||||
|
const onSighup = () => {
|
||||||
|
void exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
input.renderer.once("destroy", () => {
|
||||||
|
if (exiting) return
|
||||||
|
void cleanup().finally(() => {
|
||||||
|
win32FlushInputBuffer()
|
||||||
|
completeExit()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
process.on("SIGHUP", onSighup)
|
||||||
|
|
||||||
|
return {
|
||||||
|
exit,
|
||||||
|
exited,
|
||||||
|
async fail(error) {
|
||||||
|
exiting = true
|
||||||
|
await cleanup().catch(() => {})
|
||||||
|
if (!input.renderer.isDestroyed) input.renderer.destroy()
|
||||||
|
completeExit()
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
|
||||||
|
await ready
|
||||||
|
await exited
|
||||||
}
|
}
|
||||||
|
|
||||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ export const AttachCommand = cmd({
|
|||||||
})()
|
})()
|
||||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||||
const config = await TuiConfig.get()
|
const config = await TuiConfig.get()
|
||||||
const { tui } = await import("./app")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateSession({
|
await validateSession({
|
||||||
@@ -82,9 +81,12 @@ export const AttachCommand = cmd({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await tui({
|
const { createTuiRenderer, tui } = await import("./app")
|
||||||
|
const renderer = await createTuiRenderer(config)
|
||||||
|
const handle = tui({
|
||||||
url: args.url,
|
url: args.url,
|
||||||
config,
|
config,
|
||||||
|
renderer,
|
||||||
args: {
|
args: {
|
||||||
continue: args.continue,
|
continue: args.continue,
|
||||||
sessionID: args.session,
|
sessionID: args.session,
|
||||||
@@ -93,6 +95,7 @@ export const AttachCommand = cmd({
|
|||||||
directory,
|
directory,
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
|
await handle.done
|
||||||
} finally {
|
} finally {
|
||||||
unguard?.()
|
unguard?.()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,21 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
import * as Clipboard from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { win32FlushInputBuffer } from "../win32"
|
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
|
||||||
export function ErrorComponent(props: {
|
export function ErrorComponent(props: {
|
||||||
error: Error
|
error: Error
|
||||||
reset: () => void
|
reset: () => void
|
||||||
onBeforeExit?: () => Promise<void>
|
exit: () => Promise<void>
|
||||||
onExit: () => Promise<void>
|
|
||||||
mode?: "dark" | "light"
|
mode?: "dark" | "light"
|
||||||
}) {
|
}) {
|
||||||
const term = useTerminalDimensions()
|
const term = useTerminalDimensions()
|
||||||
const renderer = useRenderer()
|
|
||||||
|
|
||||||
const handleExit = async () => {
|
|
||||||
await props.onBeforeExit?.()
|
|
||||||
renderer.setTerminalTitle("")
|
|
||||||
renderer.destroy()
|
|
||||||
win32FlushInputBuffer()
|
|
||||||
await props.onExit()
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.ctrl && evt.name === "c") {
|
if (evt.ctrl && evt.name === "c") {
|
||||||
void handleExit()
|
void props.exit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
@@ -79,7 +68,7 @@ export function ErrorComponent(props: {
|
|||||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||||
<text fg={colors.bg}>Reset TUI</text>
|
<text fg={colors.bg}>Reset TUI</text>
|
||||||
</box>
|
</box>
|
||||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
|
||||||
<text fg={colors.bg}>Exit</text>
|
<text fg={colors.bg}>Exit</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useRenderer } from "@opentui/solid"
|
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
|
||||||
import { win32FlushInputBuffer } from "../win32"
|
export type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||||
type Exit = ((reason?: unknown) => Promise<void>) & {
|
|
||||||
message: {
|
message: {
|
||||||
set: (value?: string) => () => void
|
set: (value?: string) => () => void
|
||||||
clear: () => void
|
clear: () => void
|
||||||
@@ -10,51 +8,35 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
|
||||||
|
let message: string | undefined
|
||||||
|
let task: Promise<void> | undefined
|
||||||
|
const store = {
|
||||||
|
set: (value?: string) => {
|
||||||
|
const prev = message
|
||||||
|
message = value
|
||||||
|
return () => {
|
||||||
|
message = prev
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
message = undefined
|
||||||
|
},
|
||||||
|
get: () => message,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign(
|
||||||
|
(reason?: unknown) => {
|
||||||
|
task ??= run(reason, store.get)
|
||||||
|
return task
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: store,
|
||||||
|
},
|
||||||
|
) satisfies Exit
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||||
name: "Exit",
|
name: "Exit",
|
||||||
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
|
init: (input: { exit: Exit }) => input.exit,
|
||||||
const renderer = useRenderer()
|
|
||||||
let message: string | undefined
|
|
||||||
let task: Promise<void> | undefined
|
|
||||||
const store = {
|
|
||||||
set: (value?: string) => {
|
|
||||||
const prev = message
|
|
||||||
message = value
|
|
||||||
return () => {
|
|
||||||
message = prev
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clear: () => {
|
|
||||||
message = undefined
|
|
||||||
},
|
|
||||||
get: () => message,
|
|
||||||
}
|
|
||||||
const exit: Exit = Object.assign(
|
|
||||||
(reason?: unknown) => {
|
|
||||||
if (task) return task
|
|
||||||
task = (async () => {
|
|
||||||
await input.onBeforeExit?.()
|
|
||||||
// Reset window title before destroying renderer
|
|
||||||
renderer.setTerminalTitle("")
|
|
||||||
renderer.destroy()
|
|
||||||
win32FlushInputBuffer()
|
|
||||||
if (reason) {
|
|
||||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
|
||||||
if (formatted) {
|
|
||||||
process.stderr.write(formatted + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const text = store.get()
|
|
||||||
if (text) process.stdout.write(text + "\n")
|
|
||||||
await input.onExit?.()
|
|
||||||
})()
|
|
||||||
return task
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: store,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
process.on("SIGHUP", () => exit())
|
|
||||||
return exit
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -228,9 +228,11 @@ export const TuiThreadCommand = cmd({
|
|||||||
}, 1000).unref?.()
|
}, 1000).unref?.()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tui } = await import("./app")
|
const { createTuiRenderer, tui } = await import("./app")
|
||||||
await tui({
|
const renderer = await createTuiRenderer(config)
|
||||||
|
const handle = tui({
|
||||||
url: transport.url,
|
url: transport.url,
|
||||||
|
renderer,
|
||||||
async onSnapshot() {
|
async onSnapshot() {
|
||||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||||
const server = await client.call("snapshot", undefined)
|
const server = await client.call("snapshot", undefined)
|
||||||
@@ -249,6 +251,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
fork: args.fork,
|
fork: args.fork,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
await handle.done
|
||||||
} finally {
|
} finally {
|
||||||
await stop()
|
await stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
|
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
|
||||||
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
|
import { createExit, ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
|
||||||
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
|
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
|
||||||
import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project"
|
import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project"
|
||||||
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
|
import { SDKProvider } from "../../../../src/cli/cmd/tui/context/sdk"
|
||||||
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
|
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
|
||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
|
||||||
|
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
|
||||||
export const worktree = "/tmp/opencode"
|
|
||||||
export const directory = `${worktree}/packages/opencode`
|
|
||||||
|
|
||||||
export async function wait(fn: () => boolean, timeout = 2000) {
|
export async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
@@ -20,83 +18,6 @@ export async function wait(fn: () => boolean, timeout = 2000) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function json(data: unknown, init?: ResponseInit) {
|
|
||||||
return new Response(JSON.stringify(data), {
|
|
||||||
...init,
|
|
||||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventSource(): EventSource {
|
|
||||||
return { subscribe: async () => () => {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEventSource() {
|
|
||||||
let fn: ((event: GlobalEvent) => void) | undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
source: {
|
|
||||||
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
|
||||||
fn = handler
|
|
||||||
return () => {
|
|
||||||
if (fn === handler) fn = undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} satisfies EventSource,
|
|
||||||
emit(event: GlobalEvent) {
|
|
||||||
if (!fn) throw new Error("event source not ready")
|
|
||||||
fn(event)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
|
|
||||||
|
|
||||||
export function createFetch(override?: FetchHandler) {
|
|
||||||
const session = [] as URL[]
|
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
|
||||||
if (url.pathname === "/session") session.push(url)
|
|
||||||
|
|
||||||
const overridden = await override?.(url)
|
|
||||||
if (overridden) return overridden
|
|
||||||
|
|
||||||
switch (url.pathname) {
|
|
||||||
case "/agent":
|
|
||||||
case "/command":
|
|
||||||
case "/experimental/workspace":
|
|
||||||
case "/experimental/workspace/status":
|
|
||||||
case "/formatter":
|
|
||||||
case "/lsp":
|
|
||||||
return json([])
|
|
||||||
case "/config":
|
|
||||||
case "/experimental/resource":
|
|
||||||
case "/mcp":
|
|
||||||
case "/provider/auth":
|
|
||||||
case "/session/status":
|
|
||||||
return json({})
|
|
||||||
case "/config/providers":
|
|
||||||
return json({ providers: {}, default: {} })
|
|
||||||
case "/experimental/console":
|
|
||||||
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
|
||||||
case "/path":
|
|
||||||
return json({ home: "", state: "", config: "", worktree, directory })
|
|
||||||
case "/project/current":
|
|
||||||
return json({ id: "proj_test" })
|
|
||||||
case "/provider":
|
|
||||||
return json({ all: [], default: {}, connected: [] })
|
|
||||||
case "/session":
|
|
||||||
return json([])
|
|
||||||
case "/vcs":
|
|
||||||
return json({ branch: "main" })
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`unexpected request: ${url.pathname}`)
|
|
||||||
}) as typeof globalThis.fetch
|
|
||||||
|
|
||||||
return { fetch, session }
|
|
||||||
}
|
|
||||||
|
|
||||||
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
|
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
|
||||||
|
|
||||||
export async function mount(override?: FetchHandler) {
|
export async function mount(override?: FetchHandler) {
|
||||||
@@ -123,7 +44,7 @@ export async function mount(override?: FetchHandler) {
|
|||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
<ArgsProvider>
|
<ArgsProvider>
|
||||||
<ExitProvider>
|
<ExitProvider exit={createExit(async () => {})}>
|
||||||
<KVProvider>
|
<KVProvider>
|
||||||
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
|
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { afterEach, expect, spyOn, test } from "bun:test"
|
||||||
|
import { createTestRenderer } from "@opentui/core/testing"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime"
|
||||||
|
import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||||
|
import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio"
|
||||||
|
import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap"
|
||||||
|
|
||||||
|
type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
|
||||||
|
type TmpDir = Awaited<ReturnType<typeof tmpdir>>
|
||||||
|
|
||||||
|
const disabledInternalPlugins = {
|
||||||
|
"internal:home-footer": false,
|
||||||
|
"internal:home-tips": false,
|
||||||
|
"internal:sidebar-context": false,
|
||||||
|
"internal:sidebar-mcp": false,
|
||||||
|
"internal:sidebar-lsp": false,
|
||||||
|
"internal:sidebar-todo": false,
|
||||||
|
"internal:sidebar-files": false,
|
||||||
|
"internal:sidebar-footer": false,
|
||||||
|
"internal:plugin-manager": false,
|
||||||
|
"internal:session-v2-debug": false,
|
||||||
|
"which-key": false,
|
||||||
|
}
|
||||||
|
let active: { handle?: TuiHandle; setup?: TestRendererSetup; restore?: () => void; tmp?: TmpDir } | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const current = active
|
||||||
|
active = undefined
|
||||||
|
await current?.handle?.exit().catch(() => {})
|
||||||
|
await current?.handle?.done.catch(() => {})
|
||||||
|
await current?.handle?.ready.catch(() => {})
|
||||||
|
if (current?.setup && !current.setup.renderer.isDestroyed) current.setup.renderer.destroy()
|
||||||
|
current?.restore?.()
|
||||||
|
await Bun.sleep(20)
|
||||||
|
await current?.tmp?.[Symbol.asyncDispose]()
|
||||||
|
await TuiPluginRuntime.dispose().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns a handle immediately and resolves ready after async mount setup", async () => {
|
||||||
|
const app = await startTui()
|
||||||
|
|
||||||
|
expect(await promiseState(app.handle.ready)).toBe("pending")
|
||||||
|
|
||||||
|
app.theme.resolve("dark")
|
||||||
|
await app.handle.ready
|
||||||
|
|
||||||
|
expect(app.setup.renderer.isDestroyed).toBe(false)
|
||||||
|
expect(await promiseState(app.handle.done)).toBe("pending")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("production can await done only and still receives mount failures", async () => {
|
||||||
|
const app = await startTui({ rejectTheme: new Error("theme failed") })
|
||||||
|
|
||||||
|
await expect(app.handle.done).rejects.toThrow("theme failed")
|
||||||
|
expect(app.setup.renderer.isDestroyed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exit destroys the renderer, resolves done, and runs cleanup once", async () => {
|
||||||
|
const beforeSighup = process.listenerCount("SIGHUP")
|
||||||
|
const app = await startTui()
|
||||||
|
|
||||||
|
app.theme.resolve("dark")
|
||||||
|
await app.handle.ready
|
||||||
|
expect(process.listenerCount("SIGHUP")).toBeGreaterThan(beforeSighup)
|
||||||
|
|
||||||
|
await Promise.all([app.handle.exit(), app.handle.exit()])
|
||||||
|
await app.handle.done
|
||||||
|
|
||||||
|
expect(app.setup.renderer.isDestroyed).toBe(true)
|
||||||
|
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exit preserves reason formatting and exit messages", async () => {
|
||||||
|
const stdout: string[] = []
|
||||||
|
const stderr: string[] = []
|
||||||
|
const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
|
||||||
|
stdout.push(String(chunk))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
|
||||||
|
stderr.push(String(chunk))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await startTui()
|
||||||
|
app.theme.resolve("dark")
|
||||||
|
await app.handle.ready
|
||||||
|
|
||||||
|
app.handle.exit.message.set("goodbye")
|
||||||
|
await app.handle.exit(new Error("boom"))
|
||||||
|
await app.handle.done
|
||||||
|
|
||||||
|
expect(stderr.join("")).toContain("boom")
|
||||||
|
expect(stdout.join("")).toBe("goodbye\n")
|
||||||
|
} finally {
|
||||||
|
stdoutWrite.mockRestore()
|
||||||
|
stderrWrite.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exit before ready cancels mount and resolves done", async () => {
|
||||||
|
const app = await startTui()
|
||||||
|
|
||||||
|
await app.handle.exit()
|
||||||
|
await app.handle.done
|
||||||
|
|
||||||
|
expect(app.setup.renderer.isDestroyed).toBe(true)
|
||||||
|
await expect(app.handle.ready).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("direct renderer destruction still cleans up and resolves done", async () => {
|
||||||
|
const beforeSighup = process.listenerCount("SIGHUP")
|
||||||
|
const app = await startTui()
|
||||||
|
|
||||||
|
app.theme.resolve("dark")
|
||||||
|
await app.handle.ready
|
||||||
|
app.setup.renderer.destroy()
|
||||||
|
await app.handle.done
|
||||||
|
|
||||||
|
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("SIGHUP exits before ready and removes its listener", async () => {
|
||||||
|
const beforeSighup = process.listenerCount("SIGHUP")
|
||||||
|
const app = await startTui()
|
||||||
|
|
||||||
|
process.emit("SIGHUP")
|
||||||
|
await app.handle.done
|
||||||
|
|
||||||
|
expect(app.setup.renderer.isDestroyed).toBe(true)
|
||||||
|
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("SIGHUP exits after ready and removes its listener", async () => {
|
||||||
|
const beforeSighup = process.listenerCount("SIGHUP")
|
||||||
|
const app = await startTui()
|
||||||
|
|
||||||
|
app.theme.resolve("dark")
|
||||||
|
await app.handle.ready
|
||||||
|
process.emit("SIGHUP")
|
||||||
|
await app.handle.done
|
||||||
|
|
||||||
|
expect(app.setup.renderer.isDestroyed).toBe(true)
|
||||||
|
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("plugin, audio, and keymap cleanup run exactly once", async () => {
|
||||||
|
const originalRegister = TuiKeymap.registerOpencodeKeymap
|
||||||
|
let unregisterKeymapCalls = 0
|
||||||
|
const registerKeymap = spyOn(TuiKeymap, "registerOpencodeKeymap").mockImplementation((...args) => {
|
||||||
|
const unregister = originalRegister(...args)
|
||||||
|
return () => {
|
||||||
|
unregisterKeymapCalls++
|
||||||
|
unregister()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const disposePlugins = spyOn(TuiPluginRuntime, "dispose")
|
||||||
|
const disposeAudio = spyOn(TuiAudio, "dispose")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await startTui()
|
||||||
|
app.theme.resolve("dark")
|
||||||
|
await app.handle.ready
|
||||||
|
|
||||||
|
app.setup.renderer.destroy()
|
||||||
|
await Promise.all([app.handle.exit(), app.handle.exit()])
|
||||||
|
await app.handle.done
|
||||||
|
|
||||||
|
expect(registerKeymap).toHaveBeenCalledTimes(1)
|
||||||
|
expect(unregisterKeymapCalls).toBe(1)
|
||||||
|
expect(disposePlugins).toHaveBeenCalledTimes(1)
|
||||||
|
expect(disposeAudio).toHaveBeenCalledTimes(1)
|
||||||
|
} finally {
|
||||||
|
registerKeymap.mockRestore()
|
||||||
|
disposePlugins.mockRestore()
|
||||||
|
disposeAudio.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function startTui(options: { rejectTheme?: Error } = {}) {
|
||||||
|
const tmp = await tmpdir()
|
||||||
|
const restore = await isolateGlobalPaths(tmp.path)
|
||||||
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY })
|
||||||
|
const theme = deferred<"dark" | "light" | null>()
|
||||||
|
const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
|
||||||
|
if (options.rejectTheme) return Promise.reject(options.rejectTheme)
|
||||||
|
return theme.promise
|
||||||
|
})
|
||||||
|
setup.renderer.once("destroy", () => theme.resolve(null))
|
||||||
|
|
||||||
|
const calls = createFetch()
|
||||||
|
const events = createEventSource()
|
||||||
|
const handle = tui({
|
||||||
|
url: "http://test",
|
||||||
|
renderer: setup.renderer,
|
||||||
|
config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
|
||||||
|
directory,
|
||||||
|
fetch: calls.fetch,
|
||||||
|
events: events.source,
|
||||||
|
args: {},
|
||||||
|
})
|
||||||
|
active = {
|
||||||
|
handle,
|
||||||
|
setup,
|
||||||
|
tmp,
|
||||||
|
restore: () => {
|
||||||
|
waitForThemeMode.mockRestore()
|
||||||
|
restore()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handle, setup, theme }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isolateGlobalPaths(root: string) {
|
||||||
|
const previous = {
|
||||||
|
config: Global.Path.config,
|
||||||
|
state: Global.Path.state,
|
||||||
|
}
|
||||||
|
Global.Path.config = path.join(root, "config")
|
||||||
|
Global.Path.state = path.join(root, "state")
|
||||||
|
await mkdir(Global.Path.config, { recursive: true })
|
||||||
|
await mkdir(Global.Path.state, { recursive: true })
|
||||||
|
await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false }))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Global.Path.config = previous.config
|
||||||
|
Global.Path.state = previous.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promiseState(promise: Promise<unknown>) {
|
||||||
|
let state: "pending" | "resolved" | "rejected" = "pending"
|
||||||
|
promise.then(
|
||||||
|
() => {
|
||||||
|
state = "resolved"
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
state = "rejected"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await Promise.resolve()
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
let reject!: (error: unknown) => void
|
||||||
|
const promise = new Promise<T>((done, fail) => {
|
||||||
|
resolve = done
|
||||||
|
reject = fail
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { onMount } from "solid-js"
|
|||||||
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||||
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
|
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
|
||||||
|
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||||
|
|
||||||
const projectID = "proj_test"
|
const projectID = "proj_test"
|
||||||
|
|
||||||
@@ -46,35 +47,11 @@ function update(version: string): Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSource() {
|
|
||||||
let fn: ((event: GlobalEvent) => void) | undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
source: {
|
|
||||||
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
|
||||||
fn = handler
|
|
||||||
return () => {
|
|
||||||
if (fn === handler) fn = undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emit(evt: GlobalEvent) {
|
|
||||||
if (!fn) throw new Error("event source not ready")
|
|
||||||
fn(evt)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mount() {
|
async function mount() {
|
||||||
const source = createSource()
|
const events = createEventSource()
|
||||||
|
const calls = createFetch()
|
||||||
const seen: Event[] = []
|
const seen: Event[] = []
|
||||||
const workspaces: Array<string | undefined> = []
|
const workspaces: Array<string | undefined> = []
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
|
||||||
if (url.pathname === "/path") return Response.json({ home: "", state: "", config: "", directory: "/tmp/root" })
|
|
||||||
if (url.pathname === "/project/current") return Response.json({ id: projectID })
|
|
||||||
throw new Error(`unexpected request: ${url.pathname}`)
|
|
||||||
}) as typeof globalThis.fetch
|
|
||||||
let project!: ReturnType<typeof useProject>
|
let project!: ReturnType<typeof useProject>
|
||||||
let done!: () => void
|
let done!: () => void
|
||||||
const ready = new Promise<void>((resolve) => {
|
const ready = new Promise<void>((resolve) => {
|
||||||
@@ -82,7 +59,7 @@ async function mount() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
<SDKProvider url="http://test" directory="/tmp/root" events={source.source} fetch={fetch}>
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<Probe
|
<Probe
|
||||||
onReady={async (ctx) => {
|
onReady={async (ctx) => {
|
||||||
@@ -98,7 +75,7 @@ async function mount() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
await ready
|
await ready
|
||||||
return { app, emit: source.emit, project, seen, workspaces }
|
return { app, emit: events.emit, project, seen, workspaces }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Probe(props: {
|
function Probe(props: {
|
||||||
@@ -140,7 +117,7 @@ describe("useEvent", () => {
|
|||||||
const { app, emit, seen } = await mount()
|
const { app, emit, seen } = await mount()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" }))
|
emit(event(vcs("other"), { directory, project: "proj_other" }))
|
||||||
await Bun.sleep(30)
|
await Bun.sleep(30)
|
||||||
|
|
||||||
expect(seen).toHaveLength(0)
|
expect(seen).toHaveLength(0)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { EventSource } from "../../src/cli/cmd/tui/context/sdk"
|
||||||
|
|
||||||
|
export const worktree = "/tmp/opencode"
|
||||||
|
export const directory = `${worktree}/packages/opencode`
|
||||||
|
|
||||||
|
export function json(data: unknown, init?: ResponseInit) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
...init,
|
||||||
|
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventSource(): EventSource {
|
||||||
|
return { subscribe: async () => () => {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEventSource() {
|
||||||
|
let fn: ((event: GlobalEvent) => void) | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
||||||
|
fn = handler
|
||||||
|
return () => {
|
||||||
|
if (fn === handler) fn = undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies EventSource,
|
||||||
|
emit(event: GlobalEvent) {
|
||||||
|
if (!fn) throw new Error("event source not ready")
|
||||||
|
fn(event)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
|
||||||
|
|
||||||
|
export function createFetch(override?: FetchHandler) {
|
||||||
|
const session = [] as URL[]
|
||||||
|
const fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||||
|
if (url.pathname === "/session") session.push(url)
|
||||||
|
|
||||||
|
const overridden = await override?.(url)
|
||||||
|
if (overridden) return overridden
|
||||||
|
|
||||||
|
switch (url.pathname) {
|
||||||
|
case "/agent":
|
||||||
|
case "/command":
|
||||||
|
case "/experimental/workspace":
|
||||||
|
case "/experimental/workspace/status":
|
||||||
|
case "/formatter":
|
||||||
|
case "/lsp":
|
||||||
|
return json([])
|
||||||
|
case "/config":
|
||||||
|
case "/experimental/resource":
|
||||||
|
case "/mcp":
|
||||||
|
case "/provider/auth":
|
||||||
|
case "/session/status":
|
||||||
|
return json({})
|
||||||
|
case "/config/providers":
|
||||||
|
return json({ providers: {}, default: {} })
|
||||||
|
case "/experimental/console":
|
||||||
|
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||||
|
case "/path":
|
||||||
|
return json({ home: "", state: "", config: "", worktree, directory })
|
||||||
|
case "/project/current":
|
||||||
|
return json({ id: "proj_test" })
|
||||||
|
case "/provider":
|
||||||
|
return json({ all: [], default: {}, connected: [] })
|
||||||
|
case "/session":
|
||||||
|
return json([])
|
||||||
|
case "/vcs":
|
||||||
|
return json({ branch: "main" })
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`unexpected request: ${url.pathname}`)
|
||||||
|
}) as typeof globalThis.fetch
|
||||||
|
|
||||||
|
return { fetch, session }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user