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 116 additions and 6 deletions
+10 -2
View File
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { decodePasteBytes, stripAnsiSequences, type TextareaRenderable } from "@opentui/core"
import { useKeyboard, usePaste } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import {
createFormBodyState,
@@ -149,6 +149,14 @@ export function RunFormBody(props: {
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 next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
if (direction < 0 || confirm()) {
+15 -2
View File
@@ -1,7 +1,7 @@
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 { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { decodePasteBytes, stripAnsiSequences, 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"
@@ -265,6 +265,19 @@ 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)
setStore("custom", {
...store.custom,
[current.key]: input() + stripAnsiSequences(decodePasteBytes(event.bytes)),
})
setStore("editing", true)
})
function commitInput(text: string) {
const current = answerField()
if (!current) return false
+56 -2
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, fields?: FormWithLocation["fields"]) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -37,7 +37,7 @@ async function mountForm(root: string, width = 80) {
id: "frm_test",
sessionID: "ses_test",
title: "Authorization required",
fields: [
fields: fields ?? [
{
key: "authorization",
type: "external",
@@ -126,3 +126,57 @@ test("includes external acknowledgements in progress", async () => {
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")
expect(prompt.app.captureCharFrame()).toContain("Type your own answer")
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()
}
})
@@ -277,6 +277,41 @@ test("direct footer preserves a partial multi-field form draft across permission
}
})
test("direct footer paste opens a custom choice editor without submitting", async () => {
const replies: unknown[] = []
const app = await renderFooter({
height: 12,
view: {
type: "form",
request: {
id: "frm_custom_paste",
sessionID: "ses_child",
title: "Deployment target",
fields: [
{
key: "target",
type: "string",
options: [{ value: "staging", label: "Staging" }],
custom: true,
},
],
},
},
onFormReply: (reply) => replies.push(reply),
})
try {
await app.renderOnce()
await app.mockInput.pasteBracketedText("production\nwest")
await app.renderOnce()
expect(app.renderer.currentFocusedEditor?.plainText).toBe("production\nwest")
expect(replies).toEqual([])
} 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(