|
| 1 | +/** @jsxImportSource @opentui/solid */ |
| 2 | +import { TextareaRenderable } from "@opentui/core" |
| 3 | +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" |
| 4 | +import { testRender, useRenderer } from "@opentui/solid" |
| 5 | +import { expect, test } from "bun:test" |
| 6 | +import { mkdir } from "node:fs/promises" |
| 7 | +import path from "node:path" |
| 8 | +import { onCleanup } from "solid-js" |
| 9 | +import { tmpdir } from "../../fixture/fixture" |
| 10 | +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" |
| 11 | +import type { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind" |
| 12 | + |
| 13 | +async function wait(fn: () => boolean, timeout = 2000) { |
| 14 | + const start = Date.now() |
| 15 | + while (!fn()) { |
| 16 | + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") |
| 17 | + await Bun.sleep(10) |
| 18 | + } |
| 19 | +} |
| 20 | + |
| 21 | +async function mountPrompt(input: { |
| 22 | + root: string |
| 23 | + keybinds: Partial<TuiKeybind.Keybinds> |
| 24 | + onConfirm: (value: string) => void |
| 25 | +}) { |
| 26 | + const { Global } = await import("@opencode-ai/core/global") |
| 27 | + const previous = { |
| 28 | + config: Global.Path.config, |
| 29 | + state: Global.Path.state, |
| 30 | + } |
| 31 | + Global.Path.config = path.join(input.root, "config") |
| 32 | + Global.Path.state = path.join(input.root, "state") |
| 33 | + await mkdir(Global.Path.config, { recursive: true }) |
| 34 | + await mkdir(Global.Path.state, { recursive: true }) |
| 35 | + await Bun.write(path.join(Global.Path.state, "kv.json"), "{}") |
| 36 | + |
| 37 | + const [ |
| 38 | + { DialogProvider }, |
| 39 | + { DialogPrompt }, |
| 40 | + { KVProvider }, |
| 41 | + { ThemeProvider }, |
| 42 | + { TuiConfigProvider }, |
| 43 | + { ToastProvider }, |
| 44 | + { OpencodeKeymapProvider, registerOpencodeKeymap }, |
| 45 | + ] = await Promise.all([ |
| 46 | + import("../../../src/cli/cmd/tui/ui/dialog"), |
| 47 | + import("../../../src/cli/cmd/tui/ui/dialog-prompt"), |
| 48 | + import("../../../src/cli/cmd/tui/context/kv"), |
| 49 | + import("../../../src/cli/cmd/tui/context/theme"), |
| 50 | + import("../../../src/cli/cmd/tui/context/tui-config"), |
| 51 | + import("../../../src/cli/cmd/tui/ui/toast"), |
| 52 | + import("../../../src/cli/cmd/tui/keymap"), |
| 53 | + ]) |
| 54 | + |
| 55 | + function Harness() { |
| 56 | + const renderer = useRenderer() |
| 57 | + const keymap = createDefaultOpenTuiKeymap(renderer) |
| 58 | + const resolvedConfig = createTuiResolvedConfig({ |
| 59 | + keybinds: input.keybinds, |
| 60 | + leader_timeout: 1000, |
| 61 | + }) |
| 62 | + const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) |
| 63 | + onCleanup(off) |
| 64 | + |
| 65 | + return ( |
| 66 | + <OpencodeKeymapProvider keymap={keymap}> |
| 67 | + <TuiConfigProvider config={resolvedConfig}> |
| 68 | + <KVProvider> |
| 69 | + <ThemeProvider mode="dark"> |
| 70 | + <ToastProvider> |
| 71 | + <DialogProvider> |
| 72 | + <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} /> |
| 73 | + </DialogProvider> |
| 74 | + </ToastProvider> |
| 75 | + </ThemeProvider> |
| 76 | + </KVProvider> |
| 77 | + </TuiConfigProvider> |
| 78 | + </OpencodeKeymapProvider> |
| 79 | + ) |
| 80 | + } |
| 81 | + |
| 82 | + const app = await testRender(() => <Harness />, { kittyKeyboard: true }) |
| 83 | + return { |
| 84 | + app, |
| 85 | + async cleanup() { |
| 86 | + app.renderer.destroy() |
| 87 | + Global.Path.config = previous.config |
| 88 | + Global.Path.state = previous.state |
| 89 | + }, |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +test("dialog prompt submit wins when return is also input newline", async () => { |
| 94 | + await using tmp = await tmpdir() |
| 95 | + const confirmed: string[] = [] |
| 96 | + const prompt = await mountPrompt({ |
| 97 | + root: tmp.path, |
| 98 | + keybinds: { |
| 99 | + input_submit: "super+return", |
| 100 | + input_newline: "return,shift+return,alt+return,ctrl+j", |
| 101 | + }, |
| 102 | + onConfirm: (value) => confirmed.push(value), |
| 103 | + }) |
| 104 | + |
| 105 | + try { |
| 106 | + await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable) |
| 107 | + const textarea = prompt.app.renderer.currentFocusedEditor |
| 108 | + if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea") |
| 109 | + |
| 110 | + prompt.app.mockInput.pressEnter() |
| 111 | + |
| 112 | + expect(confirmed).toEqual(["draft"]) |
| 113 | + expect(textarea.plainText).toBe("draft") |
| 114 | + } finally { |
| 115 | + await prompt.cleanup() |
| 116 | + } |
| 117 | +}) |
| 118 | + |
| 119 | +test("dialog prompt submit can be rebound separately from input submit", async () => { |
| 120 | + await using tmp = await tmpdir() |
| 121 | + const confirmed: string[] = [] |
| 122 | + const prompt = await mountPrompt({ |
| 123 | + root: tmp.path, |
| 124 | + keybinds: { |
| 125 | + input_submit: "return", |
| 126 | + "dialog.prompt.submit": "ctrl+y", |
| 127 | + }, |
| 128 | + onConfirm: (value) => confirmed.push(value), |
| 129 | + }) |
| 130 | + |
| 131 | + try { |
| 132 | + await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable) |
| 133 | + const textarea = prompt.app.renderer.currentFocusedEditor |
| 134 | + if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea") |
| 135 | + |
| 136 | + prompt.app.mockInput.pressEnter() |
| 137 | + expect(confirmed).toEqual([]) |
| 138 | + expect(textarea.plainText).toBe("draft") |
| 139 | + |
| 140 | + prompt.app.mockInput.pressKey("y", { ctrl: true }) |
| 141 | + |
| 142 | + expect(confirmed).toEqual(["draft"]) |
| 143 | + } finally { |
| 144 | + await prompt.cleanup() |
| 145 | + } |
| 146 | +}) |
0 commit comments