feat(tui): refine question form interactions (#42493)
This commit is contained in:
@@ -12,6 +12,13 @@
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## V2 TUI Stories
|
||||
|
||||
- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`.
|
||||
- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly.
|
||||
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
|
||||
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
decodePasteBytes,
|
||||
stripAnsiSequences,
|
||||
TextAttributes,
|
||||
type ScrollBoxRenderable,
|
||||
type TextareaRenderable,
|
||||
} from "@opentui/core"
|
||||
import open from "open"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
@@ -12,6 +19,7 @@ import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { useConfig } from "../../config"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import {
|
||||
formCustom,
|
||||
formDisplayValue,
|
||||
@@ -27,7 +35,7 @@ import {
|
||||
} from "../../util/form"
|
||||
import type { FormAnswerField } from "../../util/form"
|
||||
|
||||
const FORM_MODE = "form"
|
||||
export const FORM_MODE = "form"
|
||||
|
||||
function truncate(label: string, max: number) {
|
||||
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
|
||||
@@ -43,7 +51,11 @@ function requestOptions(form: FormWithLocation) {
|
||||
}
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
export function FormPrompt(props: {
|
||||
form: FormWithLocation
|
||||
onReply?: (answer: FormAnswer) => void | Promise<void>
|
||||
onCancel?: () => void | Promise<void>
|
||||
}) {
|
||||
const client = useClient()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -58,6 +70,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const initial = formInitialValues(props.form.fields)
|
||||
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [reviewHeight, setReviewHeight] = createSignal(1)
|
||||
const [reviewScrollable, setReviewScrollable] = createSignal(false)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: initial.answers,
|
||||
@@ -70,6 +84,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
let textarea: TextareaRenderable | undefined
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
let measureReview: (() => void) | undefined
|
||||
|
||||
const message = createMemo(() => {
|
||||
const value = props.form.metadata?.["message"]
|
||||
@@ -98,16 +113,16 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
})
|
||||
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
|
||||
const tabbed = createMemo(() => {
|
||||
const width = fields().reduce((sum, item) => sum + truncate(formLabel(item), 24).length + 3, "Confirm".length + 3)
|
||||
const width = fields().reduce((sum, item) => sum + truncate(formLabel(item), 24).length + 3, "Submit".length + 3)
|
||||
return width <= dimensions().width - 8
|
||||
})
|
||||
const answered = createMemo(
|
||||
() =>
|
||||
fields().filter((item) => {
|
||||
const value = store.answers[item.key]
|
||||
return value !== undefined
|
||||
}).length,
|
||||
)
|
||||
const completed = (item: FormField) => {
|
||||
const value = store.answers[item.key]
|
||||
if (value === undefined) return false
|
||||
if (item.type === "external") return value === true
|
||||
return formValidateValue(item, value) === undefined
|
||||
}
|
||||
const answered = createMemo(() => fields().filter(completed).length)
|
||||
const field = createMemo(() => fields()[store.tab])
|
||||
const answerField = createMemo(() => {
|
||||
const current = field()
|
||||
@@ -118,16 +133,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return current?.type === "external" ? current : undefined
|
||||
})
|
||||
const confirm = createMemo(() => !single() && store.tab >= fields().length)
|
||||
const configuredRows = createMemo(() => {
|
||||
const current = answerField()
|
||||
return current ? formRows(current) : []
|
||||
})
|
||||
const rows = createMemo(() => {
|
||||
const current = answerField()
|
||||
if (!current) return []
|
||||
const configured = formRows(current)
|
||||
const configured = configuredRows()
|
||||
const value = store.answers[current.key]
|
||||
if (current.type !== "multiselect" || !Array.isArray(value)) return configured
|
||||
const known = new Set(configured.map((row) => row.value))
|
||||
return [
|
||||
...configured,
|
||||
...value.filter((item) => !known.has(item)).map((item) => ({ value: item, label: item, description: undefined })),
|
||||
...value
|
||||
.filter((item) => !known.has(item) && item !== store.custom[current.key])
|
||||
.map((item) => ({ value: item, label: item, description: undefined })),
|
||||
]
|
||||
})
|
||||
const textual = createMemo(() => {
|
||||
@@ -138,17 +159,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return formCustom(answerField())
|
||||
})
|
||||
const multi = createMemo(() => answerField()?.type === "multiselect")
|
||||
const actionLabel = createMemo(() => {
|
||||
if (confirm()) return "submit"
|
||||
const external = externalField()
|
||||
if (external) {
|
||||
if (store.answers[external.key] === true) return "continue"
|
||||
return store.externalReady[external.key] ? "I finished" : "open link"
|
||||
}
|
||||
if (multi()) return "toggle"
|
||||
if (single()) return "submit"
|
||||
return "confirm"
|
||||
})
|
||||
const placeholder = createMemo(() => {
|
||||
const current = answerField()
|
||||
if (current?.type === "string") {
|
||||
@@ -176,30 +186,92 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
if (Array.isArray(answer)) return answer.includes(value)
|
||||
return answer === value
|
||||
})
|
||||
const customChecked = createMemo(() => customPicked() || (multi() && other() && store.editing))
|
||||
const actionLabel = createMemo(() => {
|
||||
if (confirm()) return "submit"
|
||||
const external = externalField()
|
||||
if (external) {
|
||||
if (store.answers[external.key] === true) return "continue"
|
||||
return store.externalReady[external.key] ? "I finished" : "open link"
|
||||
}
|
||||
if (multi()) {
|
||||
if (other() && store.editing) return "done"
|
||||
if (other() && !input()) return "edit"
|
||||
return "toggle"
|
||||
}
|
||||
if (single()) return "submit"
|
||||
return "confirm"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
|
||||
if (!confirm()) {
|
||||
measureReview = undefined
|
||||
review = undefined
|
||||
setReviewScrollable(false)
|
||||
return
|
||||
}
|
||||
const limit = Math.max(3, dimensions().height - 14)
|
||||
const initial = Math.min(Math.max(1, fields().length), limit)
|
||||
Object.values(store.answers)
|
||||
setReviewHeight(initial)
|
||||
setReviewScrollable(false)
|
||||
measureReview = () => {
|
||||
measureReview = undefined
|
||||
const content = review?.scrollHeight ?? initial
|
||||
const height = Math.min(Math.max(1, content), limit)
|
||||
setReviewHeight(height)
|
||||
setReviewScrollable(content > height)
|
||||
}
|
||||
renderer.once(CliRenderEvents.FRAME, measureReview)
|
||||
renderer.requestRender()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
|
||||
})
|
||||
|
||||
onCleanup(
|
||||
keymap.intercept("key", ({ event, consume }) => {
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return
|
||||
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
|
||||
if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
|
||||
const current = answerField()
|
||||
if (!current) return
|
||||
updateCustom(current, input() + event.sequence)
|
||||
if (!store.editing) setStore("editing", true)
|
||||
consume()
|
||||
}),
|
||||
)
|
||||
|
||||
function answer(key: string, value: FormValue | undefined) {
|
||||
setStore("answers", { ...store.answers, [key]: value })
|
||||
const field = fields().find((item) => item.key === key)
|
||||
setStore(
|
||||
"answers",
|
||||
key,
|
||||
Array.isArray(value) && value.length === 0 && field?.type === "multiselect" && !field.required
|
||||
? undefined
|
||||
: value,
|
||||
)
|
||||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: { [field.key]: value },
|
||||
},
|
||||
requestOptions(props.form),
|
||||
function reply(answer: FormAnswer) {
|
||||
void Promise.resolve()
|
||||
.then(() =>
|
||||
props.onReply
|
||||
? props.onReply(answer)
|
||||
: client.api.form.reply(
|
||||
{ sessionID: props.form.sessionID, formID: props.form.id, answer },
|
||||
requestOptions(props.form),
|
||||
),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => setStore("error", errorMessage(error)))
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
reply({ [field.key]: value })
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
@@ -211,7 +283,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return
|
||||
}
|
||||
answer(current.key, value)
|
||||
if (customValue !== undefined) setStore("custom", { ...store.custom, [current.key]: customValue })
|
||||
if (customValue !== undefined) setStore("custom", current.key, customValue)
|
||||
if (single()) {
|
||||
replySingle(current, value)
|
||||
return
|
||||
@@ -225,18 +297,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
answer(current.key, formToggleMultiselect(store.answers[current.key], value))
|
||||
}
|
||||
|
||||
function validateCurrent() {
|
||||
if (confirm()) return true
|
||||
const current = answerField()
|
||||
if (!current) return true
|
||||
const invalid = formValidateValue(current, store.answers[current.key])
|
||||
if (!invalid) return true
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
function updateCustom(current: FormAnswerField, value: string) {
|
||||
const previous = store.custom[current.key]
|
||||
if (previous === value) return
|
||||
setStore("custom", current.key, value)
|
||||
answer(
|
||||
current.key,
|
||||
current.type === "multiselect"
|
||||
? formSetMultiselectCustom(store.answers[current.key], previous, value)
|
||||
: value || undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function selectTab(index: number) {
|
||||
if (!confirm() && index > store.tab && !validateCurrent()) return
|
||||
const next = fields()[index]
|
||||
setStore("tab", index)
|
||||
setStore("selected", next && isFormAnswerField(next) ? formSelected(next, store.answers[next.key]) : 0)
|
||||
@@ -267,23 +340,35 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
pick(row.value)
|
||||
}
|
||||
|
||||
usePaste((event) => {
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
const current = answerField()
|
||||
if (!current || textual() || !custom() || confirm()) return
|
||||
event.preventDefault()
|
||||
setStore("selected", rows().length)
|
||||
updateCustom(current, input() + stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n"))
|
||||
setStore("editing", true)
|
||||
})
|
||||
|
||||
function commitInput(text: string) {
|
||||
const current = answerField()
|
||||
if (!current) return false
|
||||
const isTextual = textual()
|
||||
const isMulti = multi()
|
||||
if (!text) {
|
||||
const previous = store.custom[current.key]
|
||||
const existing = store.answers[current.key]
|
||||
const values = Array.isArray(existing) ? existing.filter((value) => value !== previous) : []
|
||||
const value = !isTextual && isMulti && Array.isArray(existing) ? values : undefined
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
const value =
|
||||
!isTextual && isMulti
|
||||
? formSetMultiselectCustom(store.answers[current.key], store.custom[current.key], "")
|
||||
: undefined
|
||||
if (isTextual || !isMulti) {
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
}
|
||||
}
|
||||
answer(current.key, value)
|
||||
setStore("custom", { ...store.custom, [current.key]: "" })
|
||||
setStore("custom", current.key, "")
|
||||
setStore("editing", false)
|
||||
return true
|
||||
}
|
||||
@@ -321,7 +406,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
const configured = current.type === "string" && current.options?.some((option) => option.value === text)
|
||||
setStore("custom", { ...store.custom, [current.key]: isMulti || configured ? "" : text })
|
||||
setStore("custom", current.key, configured ? "" : text)
|
||||
setStore("editing", false)
|
||||
return true
|
||||
}
|
||||
@@ -352,6 +437,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (props.onCancel) {
|
||||
void props.onCancel()
|
||||
return
|
||||
}
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
}
|
||||
|
||||
@@ -404,34 +493,21 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
reply(
|
||||
Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: FORM_MODE,
|
||||
priority: 1,
|
||||
enabled: (store.editing || textual()) && !confirm(),
|
||||
commands: [
|
||||
{
|
||||
@@ -449,14 +525,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
},
|
||||
{
|
||||
bind: "escape",
|
||||
title: "Cancel answer edit",
|
||||
title: textual() ? "Dismiss form" : "Close answer edit",
|
||||
group: "Form",
|
||||
run: () => {
|
||||
if (textual()) {
|
||||
void client.api.form.cancel(
|
||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||
requestOptions(props.form),
|
||||
)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
setStore("editing", false)
|
||||
@@ -480,6 +553,17 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
submitInput(text, -1)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "up",
|
||||
title: "Leave answer edit",
|
||||
group: "Form",
|
||||
run: () => {
|
||||
if (textual() || !textarea || textarea.isDestroyed || store.selected === 0) return false
|
||||
if (textarea.scrollY + textarea.visualCursor.visualRow > 0) return false
|
||||
setStore("editing", false)
|
||||
setStore("selected", store.selected - 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "return",
|
||||
title: "Submit answer edit",
|
||||
@@ -615,6 +699,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
run: () => setStore("selected", (store.selected + 1) % total),
|
||||
},
|
||||
{ bind: "return", title: "Select answer", group: "Form", run: () => selectOption() },
|
||||
...(multi()
|
||||
? [{ bind: "space", title: "Toggle answer", group: "Form", run: () => selectOption() }]
|
||||
: []),
|
||||
{
|
||||
bind: "escape",
|
||||
title: "Dismiss form",
|
||||
@@ -643,7 +730,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!single() && !tabbed()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<box flexDirection="row" gap={3} paddingLeft={1}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
|
||||
</text>
|
||||
@@ -655,11 +742,16 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!single() && tabbed()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<box flexDirection="row" paddingLeft={1}>
|
||||
<For each={fields()}>
|
||||
{(item, index) => {
|
||||
const isTab = () => index() === store.tab
|
||||
const isAnswered = () => store.answers[item.key] !== undefined
|
||||
const color = () =>
|
||||
isTab()
|
||||
? theme.text.default
|
||||
: tabHover() === index()
|
||||
? theme.text.formfield.focused
|
||||
: theme.text.subdued
|
||||
return (
|
||||
<box
|
||||
paddingRight={2}
|
||||
@@ -677,17 +769,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
selectTabFromMouse(item)
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
isTab()
|
||||
? theme.text.formfield.selected
|
||||
: tabHover() === index()
|
||||
? theme.text.formfield.focused
|
||||
: isAnswered()
|
||||
? theme.text.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
<text fg={color()} attributes={isTab() ? TextAttributes.BOLD : undefined}>
|
||||
{truncate(formLabel(item), 24)}
|
||||
</text>
|
||||
</box>
|
||||
@@ -709,7 +791,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
selectTabFromMouse()
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? theme.text.formfield.selected : theme.text.formfield.default}>Confirm</text>
|
||||
<text
|
||||
fg={
|
||||
confirm()
|
||||
? theme.text.default
|
||||
: tabHover() === "confirm"
|
||||
? theme.text.formfield.focused
|
||||
: theme.text.subdued
|
||||
}
|
||||
attributes={confirm() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
Submit
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -724,7 +817,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
<text fg={theme.text.subdued}>{external().description}</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={theme.background.action.primary.default}
|
||||
fg={theme.text.action.primary.default}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
openExternal()
|
||||
@@ -800,30 +893,32 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
paddingRight={1}
|
||||
>
|
||||
<text
|
||||
fg={active() ? theme.text.formfield.focused : theme.text.formfield.default}
|
||||
fg={active() ? theme.text.formfield.focused : theme.text.subdued}
|
||||
>{`${i() + 1}.`}</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
? theme.text.formfield.focused
|
||||
: picked()
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.formfield.default
|
||||
}
|
||||
>
|
||||
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
|
||||
<Show when={multi()}>
|
||||
<text
|
||||
width={4}
|
||||
flexShrink={0}
|
||||
fg={picked() ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
>
|
||||
[{picked() ? "✓" : " "}]
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={active() ? theme.text.formfield.focused : theme.text.formfield.default}>
|
||||
{row.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
<text fg={theme.text.feedback.success.default}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={row.description}>
|
||||
<box paddingLeft={3}>
|
||||
<box paddingLeft={multi() ? 7 : 3}>
|
||||
<text fg={theme.text.subdued}>{row.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -845,55 +940,66 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={other() ? theme.text.formfield.focused : theme.text.formfield.default}>
|
||||
<text fg={other() ? theme.text.formfield.focused : theme.text.subdued}>
|
||||
{`${rows().length + 1}.`}
|
||||
</text>
|
||||
</box>
|
||||
<box backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}>
|
||||
<text
|
||||
fg={
|
||||
other()
|
||||
? theme.text.formfield.focused
|
||||
: customPicked()
|
||||
? theme.text.feedback.success.default
|
||||
: theme.text.default
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexGrow={1}
|
||||
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
|
||||
>
|
||||
<Show when={multi()}>
|
||||
<text
|
||||
width={4}
|
||||
flexShrink={0}
|
||||
fg={customChecked() ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
>
|
||||
[{customChecked() ? "✓" : " "}]
|
||||
</text>
|
||||
</Show>
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={other() ? theme.text.formfield.focused : theme.text.formfield.default}>
|
||||
{input() || "Type your own answer"}
|
||||
</text>
|
||||
<Show when={!multi() && customPicked()}>
|
||||
<text fg={theme.text.feedback.success.default}>✓</text>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
|
||||
</text>
|
||||
<textarea
|
||||
flexGrow={1}
|
||||
cursorStyle={config.cursor}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
val.setText(input())
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={theme.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text.formfield.focused}
|
||||
focusedTextColor={theme.text.formfield.focused}
|
||||
cursorColor={theme.text.formfield.focused}
|
||||
onContentChange={(value) => {
|
||||
const current = answerField()
|
||||
if (!current || !textarea || textarea.isDestroyed) return
|
||||
updateCustom(current, typeof value === "string" ? value : textarea.plainText)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
cursorStyle={config.cursor}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={theme.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.text.subdued}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -902,13 +1008,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
</Show>
|
||||
|
||||
<Show when={confirm()}>
|
||||
<Show when={tabbed()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text.default}>Review</text>
|
||||
</box>
|
||||
</Show>
|
||||
<scrollbox
|
||||
maxHeight={Math.min(fields().length, Math.max(3, dimensions().height - 14))}
|
||||
height={reviewHeight()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(r: ScrollBoxRenderable) => (review = r)}
|
||||
>
|
||||
@@ -976,12 +1077,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm() && !textual() && !externalField()}>
|
||||
<Show when={!confirm() && !textual() && !externalField() && !store.editing}>
|
||||
<text fg={theme.text.default}>
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={confirm() && fields().length > 0}>
|
||||
<Show when={confirm() && reviewScrollable()}>
|
||||
<text fg={theme.text.default}>
|
||||
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
|
||||
</text>
|
||||
@@ -1002,7 +1103,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text.default} onMouseUp={cancel}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>dismiss</span>
|
||||
esc <span style={{ fg: theme.text.subdued }}>{store.editing && !textual() ? "close" : "dismiss"}</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={store.error}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { ClipboardProvider } from "../../../src/context/clipboard"
|
||||
import type { FormWithLocation } from "../../../src/context/data"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
@@ -15,7 +14,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
|
||||
async function mountForm(root: string, width = 80) {
|
||||
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
|
||||
@@ -37,7 +36,7 @@ async function mountForm(root: string, width = 80) {
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
title: "Authorization required",
|
||||
fields: [
|
||||
fields: fields ?? [
|
||||
{
|
||||
key: "authorization",
|
||||
type: "external",
|
||||
@@ -57,35 +56,32 @@ async function mountForm(root: string, width = 80) {
|
||||
state,
|
||||
worktree: root,
|
||||
}}
|
||||
clipboard={{
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
write(text) {
|
||||
copied.push(text)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider
|
||||
value={{
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
write(text) {
|
||||
copied.push(text)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ConfigProvider config={config}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ClipboardProvider>
|
||||
<ConfigProvider config={config}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
|
||||
const app = await testRender(() => <Harness />, { width, height, kittyKeyboard: true })
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Authorization required"))
|
||||
return { app, copied, replies }
|
||||
@@ -129,3 +125,440 @@ test("includes external acknowledgements in progress", async () => {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows a compact confirmation summary", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
default: ["staging"],
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("right")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("enter submit"))
|
||||
|
||||
const frame = prompt.app.captureCharFrame()
|
||||
expect(frame).not.toContain("Review")
|
||||
expect(frame).not.toContain("Submit answers")
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.replies.length === 1)
|
||||
expect(prompt.replies).toEqual([{ answer: { targets: ["staging"] } }])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("confirmation fits wrapped answers before offering scroll", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(
|
||||
tmp.path,
|
||||
48,
|
||||
[
|
||||
{
|
||||
key: "targets",
|
||||
title: "Include",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{
|
||||
value: "a very long selected deliverable that wraps",
|
||||
label: "A very long selected deliverable that wraps",
|
||||
},
|
||||
],
|
||||
default: ["a very long selected deliverable that wraps"],
|
||||
},
|
||||
{
|
||||
key: "priority",
|
||||
title: "Priority",
|
||||
type: "multiselect",
|
||||
options: [{ value: "now", label: "Now" }],
|
||||
default: ["now"],
|
||||
},
|
||||
],
|
||||
40,
|
||||
)
|
||||
try {
|
||||
expect(prompt.app.captureCharFrame()).toContain("Include Priority Submit")
|
||||
prompt.app.mockInput.pressArrow("right")
|
||||
prompt.app.mockInput.pressArrow("right")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("Priority: Now") && frame.includes("enter submit"))
|
||||
|
||||
const frame = prompt.app.captureCharFrame()
|
||||
expect(frame).not.toContain("↑↓ scroll")
|
||||
const lines = frame.split("\n")
|
||||
expect(lines.findIndex((line) => line.includes("enter submit"))).toBeLessThan(20)
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("pasting on a custom choice opens its editor without submitting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
await prompt.app.mockInput.pasteBracketedText("production\nwest")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production\nwest")
|
||||
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("production"))
|
||||
expect(prompt.app.captureCharFrame()).not.toContain("Type your own answer")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("typing a custom multiselect answer selects it before commit", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
minItems: 1,
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("down")
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
|
||||
await prompt.app.mockInput.typeText("production")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[✓] production"))
|
||||
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("up leaves a custom editor only from its first visual line", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("down")
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
|
||||
const editor = prompt.app.renderer.currentFocusedEditor
|
||||
editor?.setText("production\nwest")
|
||||
if (editor) editor.cursorOffset = editor.plainText.length
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.visualCursor.visualRow === 1)
|
||||
|
||||
prompt.app.mockInput.pressArrow("up")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.visualCursor.visualRow === 0)
|
||||
expect(prompt.app.renderer.currentFocusedEditor).not.toBeNull()
|
||||
|
||||
prompt.app.mockInput.pressArrow("up")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor === null)
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[✓] Staging"))
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("typing on the highlighted custom option opens it without losing burst input", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("down")
|
||||
await prompt.app.mockInput.typeText("production target")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production target")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[✓] production target"))
|
||||
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("enter on an empty custom multiselect option clearly enters editing", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
minItems: 1,
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("down")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("enter edit"))
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[✓] Type your own answer") && frame.includes("enter done"))
|
||||
expect(prompt.app.captureCharFrame()).toContain("esc close")
|
||||
expect(prompt.app.captureCharFrame()).not.toContain("↑↓ select")
|
||||
|
||||
prompt.app.mockInput.pressEscape()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor === null)
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[ ] Type your own answer"))
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor === null)
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[ ] Type your own answer"))
|
||||
expect(prompt.app.captureCharFrame()).not.toContain("Select at least")
|
||||
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("defers multiselect validation until submission", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
required: true,
|
||||
minItems: 1,
|
||||
},
|
||||
{
|
||||
key: "priority",
|
||||
type: "multiselect",
|
||||
options: [{ value: "now", label: "Now" }],
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressEnter()
|
||||
prompt.app.mockInput.pressEnter()
|
||||
prompt.app.mockInput.pressArrow("right")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[ ] Now"))
|
||||
expect(prompt.app.captureCharFrame()).not.toContain("Select at least")
|
||||
|
||||
prompt.app.mockInput.pressArrow("right")
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("Select at least"))
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("submits an optional empty multiselect as an omitted answer", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
minItems: 1,
|
||||
},
|
||||
{
|
||||
key: "priority",
|
||||
type: "multiselect",
|
||||
options: [{ value: "now", label: "Now" }],
|
||||
default: ["now"],
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressEnter()
|
||||
prompt.app.mockInput.pressEnter()
|
||||
prompt.app.mockInput.pressArrow("right")
|
||||
prompt.app.mockInput.pressArrow("right")
|
||||
prompt.app.mockInput.pressEnter()
|
||||
|
||||
await prompt.app.waitFor(() => prompt.replies.length === 1)
|
||||
expect(prompt.replies).toEqual([{ answer: { priority: ["now"] } }])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("typing a custom single-select answer selects it without submitting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("down")
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
|
||||
await prompt.app.mockInput.typeText("production")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("production"))
|
||||
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("committing a custom multiselect answer keeps one editable custom row", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
await prompt.app.mockInput.pasteBracketedText("production")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production")
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("2. [✓] production"))
|
||||
|
||||
const frame = prompt.app.captureCharFrame()
|
||||
expect(frame).not.toContain("Type your own answer")
|
||||
expect(frame).not.toContain("3.")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("text fields retain default paste behavior", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }])
|
||||
try {
|
||||
await prompt.app.mockInput.pasteBracketedText("normal paste")
|
||||
|
||||
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("normal paste")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("pasting on a choice without custom answers does not open an editor", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
},
|
||||
])
|
||||
try {
|
||||
await prompt.app.mockInput.pasteBracketedText("production")
|
||||
|
||||
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
|
||||
expect(prompt.app.captureCharFrame()).not.toContain("production")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("space toggles the selected multiselect option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "production", label: "Production" },
|
||||
],
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressKey(" ")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[✓] Staging"))
|
||||
expect(prompt.replies).toEqual([])
|
||||
|
||||
prompt.app.mockInput.pressKey(" ")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[ ] Staging"))
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps a visible space between a multiselect marker and wrapped label", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 58, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{
|
||||
value: "responsive verification across narrow and wide terminal layouts",
|
||||
label: "Responsive verification across narrow and wide terminal layouts",
|
||||
},
|
||||
],
|
||||
default: ["responsive verification across narrow and wide terminal layouts"],
|
||||
},
|
||||
])
|
||||
try {
|
||||
expect(prompt.app.captureCharFrame()).toContain("[✓] Responsive verification")
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("space activates the custom multiselect option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "targets",
|
||||
type: "multiselect",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("down")
|
||||
prompt.app.mockInput.pressKey(" ")
|
||||
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("[✓] Type your own answer"))
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("space does not select a single-choice option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressKey(" ")
|
||||
expect(prompt.app.captureCharFrame()).not.toContain("Staging ✓")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user