mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60124ac57d |
@@ -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" ||
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user