|
|
@@ -0,0 +1,146 @@
|
|
|
+/** @jsxImportSource @opentui/solid */
|
|
|
+import { TextareaRenderable } from "@opentui/core"
|
|
|
+import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
|
+import { testRender, useRenderer } from "@opentui/solid"
|
|
|
+import { expect, test } from "bun:test"
|
|
|
+import { mkdir } from "node:fs/promises"
|
|
|
+import path from "node:path"
|
|
|
+import { onCleanup } from "solid-js"
|
|
|
+import { tmpdir } from "../../fixture/fixture"
|
|
|
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
|
|
+import type { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
|
|
|
+
|
|
|
+async function wait(fn: () => boolean, timeout = 2000) {
|
|
|
+ const start = Date.now()
|
|
|
+ while (!fn()) {
|
|
|
+ if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
|
|
+ await Bun.sleep(10)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function mountPrompt(input: {
|
|
|
+ root: string
|
|
|
+ keybinds: Partial<TuiKeybind.Keybinds>
|
|
|
+ onConfirm: (value: string) => void
|
|
|
+}) {
|
|
|
+ const { Global } = await import("@opencode-ai/core/global")
|
|
|
+ const previous = {
|
|
|
+ config: Global.Path.config,
|
|
|
+ state: Global.Path.state,
|
|
|
+ }
|
|
|
+ Global.Path.config = path.join(input.root, "config")
|
|
|
+ Global.Path.state = path.join(input.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"), "{}")
|
|
|
+
|
|
|
+ const [
|
|
|
+ { DialogProvider },
|
|
|
+ { DialogPrompt },
|
|
|
+ { KVProvider },
|
|
|
+ { ThemeProvider },
|
|
|
+ { TuiConfigProvider },
|
|
|
+ { ToastProvider },
|
|
|
+ { OpencodeKeymapProvider, registerOpencodeKeymap },
|
|
|
+ ] = await Promise.all([
|
|
|
+ import("../../../src/cli/cmd/tui/ui/dialog"),
|
|
|
+ import("../../../src/cli/cmd/tui/ui/dialog-prompt"),
|
|
|
+ import("../../../src/cli/cmd/tui/context/kv"),
|
|
|
+ import("../../../src/cli/cmd/tui/context/theme"),
|
|
|
+ import("../../../src/cli/cmd/tui/context/tui-config"),
|
|
|
+ import("../../../src/cli/cmd/tui/ui/toast"),
|
|
|
+ import("../../../src/cli/cmd/tui/keymap"),
|
|
|
+ ])
|
|
|
+
|
|
|
+ function Harness() {
|
|
|
+ const renderer = useRenderer()
|
|
|
+ const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
|
+ const resolvedConfig = createTuiResolvedConfig({
|
|
|
+ keybinds: input.keybinds,
|
|
|
+ leader_timeout: 1000,
|
|
|
+ })
|
|
|
+ const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
|
|
|
+ onCleanup(off)
|
|
|
+
|
|
|
+ return (
|
|
|
+ <OpencodeKeymapProvider keymap={keymap}>
|
|
|
+ <TuiConfigProvider config={resolvedConfig}>
|
|
|
+ <KVProvider>
|
|
|
+ <ThemeProvider mode="dark">
|
|
|
+ <ToastProvider>
|
|
|
+ <DialogProvider>
|
|
|
+ <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
|
|
|
+ </DialogProvider>
|
|
|
+ </ToastProvider>
|
|
|
+ </ThemeProvider>
|
|
|
+ </KVProvider>
|
|
|
+ </TuiConfigProvider>
|
|
|
+ </OpencodeKeymapProvider>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const app = await testRender(() => <Harness />, { kittyKeyboard: true })
|
|
|
+ return {
|
|
|
+ app,
|
|
|
+ async cleanup() {
|
|
|
+ app.renderer.destroy()
|
|
|
+ Global.Path.config = previous.config
|
|
|
+ Global.Path.state = previous.state
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+test("dialog prompt submit wins when return is also input newline", async () => {
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ const confirmed: string[] = []
|
|
|
+ const prompt = await mountPrompt({
|
|
|
+ root: tmp.path,
|
|
|
+ keybinds: {
|
|
|
+ input_submit: "super+return",
|
|
|
+ input_newline: "return,shift+return,alt+return,ctrl+j",
|
|
|
+ },
|
|
|
+ onConfirm: (value) => confirmed.push(value),
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
|
|
+ const textarea = prompt.app.renderer.currentFocusedEditor
|
|
|
+ if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
|
|
|
+
|
|
|
+ prompt.app.mockInput.pressEnter()
|
|
|
+
|
|
|
+ expect(confirmed).toEqual(["draft"])
|
|
|
+ expect(textarea.plainText).toBe("draft")
|
|
|
+ } finally {
|
|
|
+ await prompt.cleanup()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+test("dialog prompt submit can be rebound separately from input submit", async () => {
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ const confirmed: string[] = []
|
|
|
+ const prompt = await mountPrompt({
|
|
|
+ root: tmp.path,
|
|
|
+ keybinds: {
|
|
|
+ input_submit: "return",
|
|
|
+ "dialog.prompt.submit": "ctrl+y",
|
|
|
+ },
|
|
|
+ onConfirm: (value) => confirmed.push(value),
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
|
|
+ const textarea = prompt.app.renderer.currentFocusedEditor
|
|
|
+ if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
|
|
|
+
|
|
|
+ prompt.app.mockInput.pressEnter()
|
|
|
+ expect(confirmed).toEqual([])
|
|
|
+ expect(textarea.plainText).toBe("draft")
|
|
|
+
|
|
|
+ prompt.app.mockInput.pressKey("y", { ctrl: true })
|
|
|
+
|
|
|
+ expect(confirmed).toEqual(["draft"])
|
|
|
+ } finally {
|
|
|
+ await prompt.cleanup()
|
|
|
+ }
|
|
|
+})
|