refactor(tui): extract standalone package (#31193)
This commit is contained in:
parent
7a2c49e762
commit
106f8e94d6
84 changed files with 1147 additions and 1918 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
||||
import { createTuiAttention } from "@/cli/tui/attention"
|
||||
import { createTuiAttention } from "@opencode-ai/tui/attention"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
type FocusEvent = "focus" | "blur"
|
||||
|
|
|
|||
|
|
@ -1,330 +0,0 @@
|
|||
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 { tui, type TuiHandle } from "@opencode-ai/tui"
|
||||
import { createLegacyTuiHost } from "../../../src/cli/tui/host"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
import * as TuiAudio from "../../../src/cli/tui/audio"
|
||||
import * as TuiKeymap from "@opencode-ai/tui/keymap"
|
||||
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
|
||||
|
||||
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]()
|
||||
})
|
||||
|
||||
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("plugin startup failure does not fail the app", async () => {
|
||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||
try {
|
||||
const app = await startTui({ rejectPlugins: new Error("plugins failed") })
|
||||
app.theme.resolve("dark")
|
||||
|
||||
await expect(app.handle.ready).resolves.toBeUndefined()
|
||||
await app.pluginHost.started
|
||||
expect(app.setup.renderer.isDestroyed).toBe(false)
|
||||
expect(app.pluginHost.starts).toBe(1)
|
||||
await app.handle.exit()
|
||||
await app.handle.done
|
||||
} finally {
|
||||
error.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
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")).toBeLessThanOrEqual(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")).toBeLessThanOrEqual(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")).toBeLessThanOrEqual(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 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(app.pluginHost.disposes).toBe(1)
|
||||
expect(disposeAudio).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
registerKeymap.mockRestore()
|
||||
disposeAudio.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("plugin disposal failure does not stop remaining cleanup", async () => {
|
||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||
const disposeAudio = spyOn(TuiAudio, "dispose")
|
||||
try {
|
||||
const app = await startTui({ rejectPluginDispose: new Error("dispose failed") })
|
||||
app.theme.resolve("dark")
|
||||
await app.handle.ready
|
||||
|
||||
await app.handle.exit()
|
||||
await app.handle.done
|
||||
|
||||
expect(app.pluginHost.disposes).toBe(1)
|
||||
expect(disposeAudio).toHaveBeenCalledTimes(1)
|
||||
expect(app.setup.renderer.isDestroyed).toBe(true)
|
||||
} finally {
|
||||
error.mockRestore()
|
||||
disposeAudio.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
async function startTui(options: { rejectTheme?: Error; rejectPlugins?: Error; rejectPluginDispose?: Error } = {}) {
|
||||
const tmp = await tmpdir()
|
||||
const isolated = 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 pluginStarted = deferred<void>()
|
||||
const pluginHost = {
|
||||
starts: 0,
|
||||
disposes: 0,
|
||||
started: pluginStarted.promise,
|
||||
async start() {
|
||||
pluginHost.starts++
|
||||
pluginStarted.resolve()
|
||||
if (options.rejectPlugins) throw options.rejectPlugins
|
||||
},
|
||||
async dispose() {
|
||||
pluginHost.disposes++
|
||||
if (options.rejectPluginDispose) throw options.rejectPluginDispose
|
||||
},
|
||||
}
|
||||
const environment = createTuiEnvironment({
|
||||
cwd: tmp.path,
|
||||
platform: "linux",
|
||||
paths: { home: tmp.path, state: isolated.state, worktree: path.join(tmp.path, "worktree") },
|
||||
capabilities: {
|
||||
mouse: true,
|
||||
copyOnSelect: true,
|
||||
terminalTitle: false,
|
||||
terminalSuspend: false,
|
||||
workspaces: false,
|
||||
showTimeToFirstDraw: false,
|
||||
},
|
||||
terminal: {},
|
||||
editor: { zedTerminal: false },
|
||||
skipInitialLoading: false,
|
||||
})
|
||||
const handle = tui({
|
||||
environment,
|
||||
build: createTuiBuildInfo({ version: "test", channel: "test" }),
|
||||
url: "http://test",
|
||||
renderer: setup.renderer,
|
||||
host: createLegacyTuiHost(setup.renderer),
|
||||
config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
|
||||
directory,
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
pluginHost,
|
||||
args: {},
|
||||
})
|
||||
active = {
|
||||
handle,
|
||||
setup,
|
||||
tmp,
|
||||
restore: () => {
|
||||
waitForThemeMode.mockRestore()
|
||||
isolated.restore()
|
||||
},
|
||||
}
|
||||
|
||||
return { handle, setup, theme, pluginHost }
|
||||
}
|
||||
|
||||
async function isolateGlobalPaths(root: string) {
|
||||
const previous = Global.Path.config
|
||||
Global.Path.config = path.join(root, "config")
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(Global.Path.config, { recursive: true })
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
|
||||
|
||||
return {
|
||||
state,
|
||||
restore() {
|
||||
Global.Path.config = previous
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
describe("tui attach", () => {
|
||||
test("loads the public TUI API and legacy hosts lazily", async () => {
|
||||
test("loads the TUI integration lazily", async () => {
|
||||
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/)
|
||||
expect(source).toContain('await import("../tui/host")')
|
||||
expect(source).toContain('await import("../tui/layer")')
|
||||
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
|
||||
expect(source).not.toContain('import("./app")')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { mkdir, symlink } from "node:fs/promises"
|
|||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, expect, spyOn, test } from "bun:test"
|
||||
import { isZedTerminal, offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/tui/editor-zed"
|
||||
import {
|
||||
isZedTerminal,
|
||||
offsetToPosition,
|
||||
resolveZedDbPath,
|
||||
resolveZedSelection,
|
||||
} from "@opencode-ai/tui/editor-zed"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const originalZedTerm = process.env.ZED_TERM
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@ import os from "node:os"
|
|||
import path from "node:path"
|
||||
import { afterEach, expect, spyOn, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { EditorContextProvider, useEditorContext } from "@opencode-ai/tui/context/editor"
|
||||
import { EditorContextProvider, useEditorContext, type EditorIntegration } from "@opencode-ai/tui/context/editor"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { FakeWebSocket } from "../../lib/websocket"
|
||||
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
|
||||
import { TuiPlatformProvider, type TuiPlatform } from "@opencode-ai/tui/platform"
|
||||
import { discoverEditorConnection } from "../../../src/cli/tui/platform"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { discoverEditorConnection } from "@opencode-ai/tui/editor"
|
||||
|
||||
const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
|
||||
const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
|
||||
|
|
@ -36,17 +35,14 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
|
|||
|
||||
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
|
||||
return (
|
||||
<TestTuiEnvironmentProvider
|
||||
<TestTuiContexts
|
||||
cwd={process.cwd()}
|
||||
paths={{ home: os.homedir() }}
|
||||
editor={{ port: value ? Number.parseInt(value, 10) : undefined }}
|
||||
>
|
||||
<TuiPlatformProvider value={platform}>
|
||||
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
|
||||
<EditorContextProvider integration={editorService} WebSocketImpl={WebSocketImpl}>
|
||||
<Consumer />
|
||||
</EditorContextProvider>
|
||||
</TuiPlatformProvider>
|
||||
</TestTuiEnvironmentProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -56,16 +52,8 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
|
|||
}
|
||||
}
|
||||
|
||||
const platform: TuiPlatform = {
|
||||
files: {
|
||||
readText: (file) => Bun.file(file).text(),
|
||||
readBytes: (file) => Bun.file(file).bytes(),
|
||||
mime: () => Promise.resolve("application/octet-stream"),
|
||||
},
|
||||
editor: {
|
||||
open: () => Promise.resolve(undefined),
|
||||
connection: discoverEditorConnection,
|
||||
},
|
||||
const editorService: EditorIntegration = {
|
||||
connection: discoverEditorConnection,
|
||||
}
|
||||
|
||||
function createWebSocketImpl(...sockets: FakeWebSocket[]) {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,10 @@ import { tmpdir } from "../../fixture/fixture"
|
|||
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui"
|
||||
|
||||
describe("tui thread", () => {
|
||||
test("loads the public TUI API and legacy hosts lazily", async () => {
|
||||
test("loads the TUI integration lazily", async () => {
|
||||
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/)
|
||||
expect(source).toContain('await import("../tui/host")')
|
||||
expect(source).toContain('await import("../tui/layer")')
|
||||
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
|
||||
expect(source).not.toContain('import("./app")')
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue