Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ When in search mode, `Enter` submits the search, and `Escape` clears search high

### Dialog controls

Searchable dialogs and custom question answer inputs use modal controls.
Searchable dialogs, prompt dialogs, and custom question answer inputs use modal controls.

| Area | Controls |
| ------------------ | ----------------------------------------------------------------------- |
Expand Down Expand Up @@ -262,7 +262,7 @@ Yank and delete operations sync to the system clipboard, `p` / `P` paste from it

### Modal dialog inputs

Disable modal controls in searchable dialogs and custom question answer inputs:
Disable modal controls in searchable dialogs, prompt dialogs, and custom question answer inputs:

```json
{
Expand Down
76 changes: 71 additions & 5 deletions packages/tui/src/ui/dialog-prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useTuiConfig } from "../config"
import { useBindings, useCommandShortcut } from "../keymap"
import { OPENCODE_VIM_MODE_KEY, useBindings, useCommandShortcut, useOpencodeKeymap } from "../keymap"
import { useVimEnabled } from "../component/vim"
import {
createModalInputControls,
type ModalInputKeyEvent,
type ModalInputMode,
} from "../component/vim/modal-input-controls"
import { vimCursorStyle } from "../component/vim/cursor-style"

export type DialogPromptProps = {
title: string
Expand All @@ -21,12 +28,56 @@ export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const keymap = useOpencodeKeymap()
const vimEnabled = useVimEnabled()
const previousVimMode = keymap.getData(OPENCODE_VIM_MODE_KEY)
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
const [inputMode, setInputMode] = createSignal<ModalInputMode>("insert")
const modalInputEnabled = createMemo(() => vimEnabled() && tuiConfig.vim_modal_input)
let textarea: TextareaRenderable

createEffect(() => {
keymap.setData(OPENCODE_VIM_MODE_KEY, modalInputEnabled() ? inputMode() : undefined)
})

onCleanup(() => {
keymap.setData(OPENCODE_VIM_MODE_KEY, previousVimMode)
})

const modalInput = createModalInputControls({
mode: inputMode,
setMode: setInputMode,
focus: () => textarea?.focus(),
text: () => textarea?.plainText ?? "",
cursor: () => textarea?.cursorOffset ?? 0,
setCursor: (offset) => {
if (!textarea || textarea.isDestroyed) return
textarea.cursorOffset = offset
},
setText: (text) => {
if (!textarea || textarea.isDestroyed) return
textarea.setText(text)
},
langmap: () => tuiConfig.vim_langmap,
vimEscapeSequence: () => tuiConfig.vim_escape_sequence,
})

const cursorStyle = createMemo(() => vimCursorStyle(modalInputEnabled() ? inputMode() : undefined, tuiConfig.cursor))

createEffect(() => {
if (!textarea || textarea.isDestroyed) return
textarea.cursorStyle = cursorStyle()
})

function enterNormalMode() {
modalInput.clearPending()
modalInput.enterNormal()
}

function confirm() {
if (props.busy) return
modalInput.clearPending()
props.onConfirm?.(textarea.plainText)
}

Expand All @@ -43,7 +94,19 @@ export function DialogPrompt(props: DialogPromptProps) {
run: confirm,
},
],
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
bindings: [
...tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
...(modalInputEnabled() && inputMode() === "insert"
? [
{
key: "escape",
desc: "Enter normal mode",
group: "Dialog",
cmd: enterNormalMode,
},
]
: []),
],
}))

onMount(() => {
Expand Down Expand Up @@ -95,8 +158,11 @@ export function DialogPrompt(props: DialogPromptProps) {
placeholderColor={theme.textMuted}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
cursorStyle={tuiConfig.cursor}
cursorColor={props.busy ? theme.backgroundElement : theme.primary}
cursorStyle={cursorStyle()}
onKeyDown={(event: ModalInputKeyEvent) => {
if (modalInputEnabled() && !props.busy && modalInput.handleKey(event)) return
}}
/>
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working…"}</Spinner>
Expand Down
106 changes: 103 additions & 3 deletions packages/tui/test/cli/tui/dialog-prompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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 { onCleanup, onMount } from "solid-js"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import type { TuiKeybind } from "../../../src/config/keybind"
Expand All @@ -22,14 +22,19 @@ async function wait(fn: () => boolean, timeout = 2000) {
async function mountPrompt(input: {
root: string
keybinds: Partial<TuiKeybind.Keybinds>
config?: {
vim?: boolean
vim_modal_input?: boolean
}
onConfirm: (value: string) => void
onClose?: () => void
}) {
const state = path.join(input.root, "state")
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), "{}")

const [
{ DialogProvider },
{ DialogProvider, useDialog },
{ DialogPrompt },
{ KVProvider },
{ ThemeProvider },
Expand All @@ -52,10 +57,22 @@ async function mountPrompt(input: {
const resolvedConfig = createTuiResolvedConfig({
keybinds: input.keybinds,
leader_timeout: 1000,
...input.config,
})
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
onCleanup(off)

function PromptLauncher() {
const dialog = useDialog()
onMount(() => {
dialog.replace(
() => <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />,
input.onClose,
)
})
return null
}

return (
<TestTuiContexts
directory={input.root}
Expand All @@ -71,7 +88,7 @@ async function mountPrompt(input: {
<ThemeProvider mode="dark">
<ToastProvider>
<DialogProvider>
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
<PromptLauncher />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
Expand Down Expand Up @@ -117,6 +134,89 @@ test("dialog prompt submit wins when return is also input newline", async () =>
}
})

test("dialog prompt enters normal mode, handles motions, then dismisses on escape", async () => {
await using tmp = await tmpdir()
let closed = 0
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {},
config: {
vim: true,
vim_modal_input: true,
},
onConfirm: () => {},
onClose: () => closed++,
})

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")

expect(textarea.cursorOffset).toBe(5)
prompt.app.mockInput.pressKey("ESCAPE")
expect(textarea.cursorOffset).toBe(4)
expect(closed).toBe(0)

prompt.app.mockInput.pressKey("h")
expect(textarea.cursorOffset).toBe(3)
expect(textarea.plainText).toBe("draft")

prompt.app.mockInput.pressKey("ESCAPE")
expect(closed).toBe(1)
} finally {
await prompt.cleanup()
}
})

test("dialog prompt keeps non-Vim input behavior when modal input is disabled", async () => {
await using tmp = await tmpdir()
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {},
config: {
vim: true,
vim_modal_input: false,
},
onConfirm: () => {},
})

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.pressKey("h")
expect(textarea.plainText).toBe("drafth")
} finally {
await prompt.cleanup()
}
})

test("dialog prompt keeps non-Vim input behavior when Vim is disabled globally", async () => {
await using tmp = await tmpdir()
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {},
config: {
vim: false,
vim_modal_input: true,
},
onConfirm: () => {},
})

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.pressKey("h")
expect(textarea.plainText).toBe("drafth")
} finally {
await prompt.cleanup()
}
})

test("dialog prompt submit can be rebound separately from input submit", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
Expand Down
Loading