Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton eb45ada74e fix(tui): preserve burst form input 2026-08-07 10:39:39 -04:00
Kit Langton 120312286a feat(tui): type into custom form answers 2026-08-07 10:32:07 -04:00
4 changed files with 102 additions and 4 deletions
+18
View File
@@ -71,6 +71,7 @@ export function RunFormBody(props: {
return typeof value === "string" ? value : undefined
})
let area: TextareaRenderable | undefined
let editingReady = false
createEffect(() => {
setState((previous) => formSync(previous, props.request))
@@ -93,6 +94,7 @@ export function RunFormBody(props: {
if (!area || area.isDestroyed || !state().editing) return
area.focus()
area.cursorOffset = area.plainText.length
editingReady = true
})
})
@@ -209,6 +211,22 @@ export function RunFormBody(props: {
return
}
if (unsupported()) return
const character =
!event.ctrl &&
!event.meta &&
!event.option &&
!event.super &&
!event.hyper &&
/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)
? event.sequence
: undefined
if (custom() && state().selected === rows().length && character && !editingReady) {
const next = state().editing ? state() : formPick(state(), props.request)
if (!state().editing) editingReady = false
setState(formSetDraft(next, current(), formInput(next, current()) + character))
event.preventDefault()
return
}
if (state().editing) return
if (
event.name === "tab" ||
+20
View File
@@ -67,6 +67,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
})
let textarea: TextareaRenderable | undefined
let editingReady = false
let review: ScrollBoxRenderable | undefined
const message = createMemo(() => {
@@ -175,6 +176,23 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return answer === value
})
onCleanup(
keymap.intercept("key", ({ event, consume }) => {
if (keymap.mode.current() !== FORM_MODE) return
if (textual() || !other() || (store.editing && editingReady)) return
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
if (!/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
const current = answerField()
if (!current) return
setStore("custom", { ...store.custom, [current.key]: input() + event.sequence })
if (!store.editing) {
editingReady = false
setStore("editing", true)
}
consume()
}),
)
function answer(key: string, value: FormValue | undefined) {
setStore("answers", { ...store.answers, [key]: value })
setStore("error", "")
@@ -870,8 +888,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
val.setText(input())
val.focus()
val.gotoLineEnd()
editingReady = true
})
}}
initialValue={input()}
+33 -4
View File
@@ -15,7 +15,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, input?: FormWithLocation) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -33,7 +33,7 @@ async function mountForm(root: string, width = 80) {
events,
)
const config = createTuiResolvedConfig()
const form = {
const form = input ?? ({
id: "frm_test",
sessionID: "ses_test",
title: "Authorization required",
@@ -45,7 +45,7 @@ async function mountForm(root: string, width = 80) {
title: "Authorize access",
},
],
} satisfies FormWithLocation
} satisfies FormWithLocation)
const { FormPrompt } = await import("../../../src/routes/session/form")
function Harness() {
@@ -84,7 +84,7 @@ async function mountForm(root: string, width = 80) {
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Authorization required"))
await app.waitForFrame((frame) => frame.includes(form.title))
return { app, copied, replies }
}
@@ -126,3 +126,32 @@ test("includes external acknowledgements in progress", async () => {
prompt.app.renderer.destroy()
}
})
test("typing starts a highlighted custom answer without losing the first character", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, {
id: "frm_test",
sessionID: "ses_test",
title: "Choose a target",
fields: [
{
key: "target",
type: "string",
options: [{ value: "production", label: "Production" }],
custom: true,
},
],
})
try {
prompt.app.mockInput.pressKey("x")
await prompt.app.renderOnce()
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
prompt.app.mockInput.pressKey("j")
await prompt.app.mockInput.typeText("123")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "123")
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("123")
} finally {
prompt.app.renderer.destroy()
}
})
@@ -277,6 +277,37 @@ test("direct footer preserves a partial multi-field form draft across permission
}
})
test("direct footer typing starts a highlighted custom answer without losing the first character", async () => {
const request: FormInfo = {
id: "frm_custom",
sessionID: "ses_child",
title: "Choose a target",
fields: [
{
key: "target",
type: "string",
options: [{ value: "production", label: "Production" }],
custom: true,
},
],
}
const app = await renderFooter({ height: 12, view: { type: "form", request } })
try {
await app.renderOnce()
app.mockInput.pressKey("x")
await app.renderOnce()
expect(app.renderer.currentFocusedEditor).toBeNull()
app.mockInput.pressKey("j")
await app.mockInput.typeText("hello")
await app.waitFor(() => app.renderer.currentFocusedEditor?.plainText === "hello")
expect(app.renderer.currentFocusedEditor?.plainText).toBe("hello")
} finally {
app.cleanup()
}
})
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(