opencode/packages/tui/src/ui/dialog-prompt.tsx
opencode-agent[bot] 016545513f
feat(tui): group sequential thinking (#36901)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-14 15:13:16 -04:00

125 lines
3.7 KiB
TypeScript

import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
export type DialogPromptProps = {
title: string
description?: () => JSX.Element
placeholder?: string
value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const shortcuts = Keymap.useShortcuts()
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
function confirm() {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}
Keymap.createLayer(() => ({
mode: "modal",
target: textareaTarget,
enabled: textareaTarget() !== undefined && !props.busy,
// Dialog form semantics must win over the global managed textarea input layer.
priority: 1,
commands: [
{
id: "dialog.prompt.submit",
title: "Submit dialog prompt",
bind: "return",
group: "Dialog",
run: confirm,
},
],
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) return
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
createEffect(() => {
if (!textarea || textarea.isDestroyed) return
const traits = props.busy
? {
suspend: true,
status: "BUSY",
}
: {}
textarea.traits = traits
if (props.busy) {
textarea.blur()
return
}
textarea.focus()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={1}>
{props.description?.()}
<textarea
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
placeholderColor={theme.textMuted}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
/>
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={theme.text}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</Show>
</box>
</box>
)
}
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
return new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
),
() => resolve(null),
)
})
}