Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 60124ac57d feat(tui): paste into custom form answers 2026-08-07 10:32:08 -04:00
4 changed files with 100 additions and 88 deletions
+10 -20
View File
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import type { TextareaRenderable } from "@opentui/core" import { decodePasteBytes, stripAnsiSequences, type TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid" import { useKeyboard, usePaste } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { import {
createFormBodyState, createFormBodyState,
@@ -71,7 +71,6 @@ export function RunFormBody(props: {
return typeof value === "string" ? value : undefined return typeof value === "string" ? value : undefined
}) })
let area: TextareaRenderable | undefined let area: TextareaRenderable | undefined
let editingReady = false
createEffect(() => { createEffect(() => {
setState((previous) => formSync(previous, props.request)) setState((previous) => formSync(previous, props.request))
@@ -94,7 +93,6 @@ export function RunFormBody(props: {
if (!area || area.isDestroyed || !state().editing) return if (!area || area.isDestroyed || !state().editing) return
area.focus() area.focus()
area.cursorOffset = area.plainText.length area.cursorOffset = area.plainText.length
editingReady = true
}) })
}) })
@@ -151,6 +149,14 @@ export function RunFormBody(props: {
if (formSingle(props.request)) submit(next) if (formSingle(props.request)) submit(next)
} }
usePaste((event) => {
const field = current()
if (!field || textual() || !custom() || confirm()) return
event.preventDefault()
const next = formPick(formSetSelected(state(), rows().length), props.request)
setState(formSetDraft(next, field, formInput(next, field) + stripAnsiSequences(decodePasteBytes(event.bytes))))
})
const moveField = (direction: -1 | 1) => { const moveField = (direction: -1 | 1) => {
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1) const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
if (direction < 0 || confirm()) { if (direction < 0 || confirm()) {
@@ -211,22 +217,6 @@ export function RunFormBody(props: {
return return
} }
if (unsupported()) 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 (state().editing) return
if ( if (
event.name === "tab" || event.name === "tab" ||
+15 -22
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import { decodePasteBytes, stripAnsiSequences, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
import open from "open" import open from "open"
import { useTheme, useThemes } from "../../context/theme" import { useTheme, useThemes } from "../../context/theme"
import type { FormField, FormValue } from "@opencode-ai/client" import type { FormField, FormValue } from "@opencode-ai/client"
@@ -67,7 +67,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}) })
let textarea: TextareaRenderable | undefined let textarea: TextareaRenderable | undefined
let editingReady = false
let review: ScrollBoxRenderable | undefined let review: ScrollBoxRenderable | undefined
const message = createMemo(() => { const message = createMemo(() => {
@@ -176,23 +175,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return answer === value 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) { function answer(key: string, value: FormValue | undefined) {
setStore("answers", { ...store.answers, [key]: value }) setStore("answers", { ...store.answers, [key]: value })
setStore("error", "") setStore("error", "")
@@ -283,6 +265,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
pick(row.value) 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)
setStore("custom", {
...store.custom,
[current.key]: input() + stripAnsiSequences(decodePasteBytes(event.bytes)),
})
setStore("editing", true)
})
function commitInput(text: string) { function commitInput(text: string) {
const current = answerField() const current = answerField()
if (!current) return false if (!current) return false
@@ -888,10 +883,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val textarea = val
val.traits = { status: "ANSWER" } val.traits = { status: "ANSWER" }
queueMicrotask(() => { queueMicrotask(() => {
val.setText(input())
val.focus() val.focus()
val.gotoLineEnd() val.gotoLineEnd()
editingReady = true
}) })
}} }}
initialValue={input()} initialValue={input()}
+51 -26
View File
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80, input?: FormWithLocation) { async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"]) {
const state = path.join(root, "state") const state = path.join(root, "state")
await mkdir(state, { recursive: true }) await mkdir(state, { recursive: true })
@@ -33,11 +33,11 @@ async function mountForm(root: string, width = 80, input?: FormWithLocation) {
events, events,
) )
const config = createTuiResolvedConfig() const config = createTuiResolvedConfig()
const form = input ?? ({ const form = {
id: "frm_test", id: "frm_test",
sessionID: "ses_test", sessionID: "ses_test",
title: "Authorization required", title: "Authorization required",
fields: [ fields: fields ?? [
{ {
key: "authorization", key: "authorization",
type: "external", type: "external",
@@ -45,7 +45,7 @@ async function mountForm(root: string, width = 80, input?: FormWithLocation) {
title: "Authorize access", title: "Authorize access",
}, },
], ],
} satisfies FormWithLocation) } satisfies FormWithLocation
const { FormPrompt } = await import("../../../src/routes/session/form") const { FormPrompt } = await import("../../../src/routes/session/form")
function Harness() { function Harness() {
@@ -84,7 +84,7 @@ async function mountForm(root: string, width = 80, input?: FormWithLocation) {
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true }) const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
app.renderer.start() app.renderer.start()
await app.waitForFrame((frame) => frame.includes(form.title)) await app.waitForFrame((frame) => frame.includes("Authorization required"))
return { app, copied, replies } return { app, copied, replies }
} }
@@ -127,30 +127,55 @@ test("includes external acknowledgements in progress", async () => {
} }
}) })
test("typing starts a highlighted custom answer without losing the first character", async () => { test("pasting on a custom choice opens its editor without submitting", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, { const prompt = await mountForm(tmp.path, 80, [
id: "frm_test", {
sessionID: "ses_test", key: "target",
title: "Choose a target", type: "string",
fields: [ options: [{ value: "staging", label: "Staging" }],
{ custom: true,
key: "target", },
type: "string", ])
options: [{ value: "production", label: "Production" }],
custom: true,
},
],
})
try { try {
prompt.app.mockInput.pressKey("x") await prompt.app.mockInput.pasteBracketedText("production\nwest")
await prompt.app.renderOnce() await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production\nwest")
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
prompt.app.mockInput.pressKey("j") expect(prompt.app.captureCharFrame()).toContain("Type your own answer")
await prompt.app.mockInput.typeText("123") expect(prompt.replies).toEqual([])
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "123") } finally {
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("123") 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 { } finally {
prompt.app.renderer.destroy() prompt.app.renderer.destroy()
} }
+24 -20
View File
@@ -277,32 +277,36 @@ 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 () => { test("direct footer paste opens a custom choice editor without submitting", async () => {
const request: FormInfo = { const replies: unknown[] = []
id: "frm_custom", const app = await renderFooter({
sessionID: "ses_child", height: 12,
title: "Choose a target", view: {
fields: [ type: "form",
{ request: {
key: "target", id: "frm_custom_paste",
type: "string", sessionID: "ses_child",
options: [{ value: "production", label: "Production" }], title: "Deployment target",
custom: true, fields: [
{
key: "target",
type: "string",
options: [{ value: "staging", label: "Staging" }],
custom: true,
},
],
}, },
], },
} onFormReply: (reply) => replies.push(reply),
const app = await renderFooter({ height: 12, view: { type: "form", request } }) })
try { try {
await app.renderOnce() await app.renderOnce()
app.mockInput.pressKey("x") await app.mockInput.pasteBracketedText("production\nwest")
await app.renderOnce() await app.renderOnce()
expect(app.renderer.currentFocusedEditor).toBeNull()
app.mockInput.pressKey("j") expect(app.renderer.currentFocusedEditor?.plainText).toBe("production\nwest")
await app.mockInput.typeText("hello") expect(replies).toEqual([])
await app.waitFor(() => app.renderer.currentFocusedEditor?.plainText === "hello")
expect(app.renderer.currentFocusedEditor?.plainText).toBe("hello")
} finally { } finally {
app.cleanup() app.cleanup()
} }