opentui(v6): paste-while-unfocused + clarify prompt rewrite (F4/F5/F6)

- F4: a paste while the composer is unfocused (transcript scrollbox grabbed
  focus) now lands — a renderer-level paste listener focuses the textarea and
  applies the bytes; the focused path stays the textarea's own onPaste (no
  double insert). Paste logic shared via applyPaste(text, native).
- F5: clarify prompt rewritten off the native <select> onto a custom list —
  long options WRAP instead of clipping, options are numbered, the selected
  row gets a real background + accent (three signals), and the custom answer
  is an always-present inline <input> in the same screen.
- F6: Up/Down/Enter are preventDefault'd so arrows drive selection and never
  leak to the transcript scrollbox.

Verified live via tmux screenshot (wrapping + numbering + highlight + inline
input all correct). 714 tests green; new clarifyPrompt.test.tsx covers wrap,
numbering, selection, inline custom input, no-choices, Esc cancel.
This commit is contained in:
alt-glitch 2026-06-13 19:14:25 +05:30
parent 5268027e6b
commit ef9232a2f7
3 changed files with 183 additions and 16 deletions

View file

@ -0,0 +1,134 @@
/**
* ClarifyPrompt rewrite (F5/F6) headless frames + simulated keyboard.
*
* Asserts the four user-reported fixes:
* - long option text WRAPS (appears on a second line) instead of clipping (F5),
* - options are NUMBERED and the selected row is highlighted (F5),
* - the custom answer is an inline input in the SAME screen (F5),
* - Up/Down drive the selection and Enter answers the highlighted choice; the
* arrows don't escape to a scrollbox (F6 we assert selection moved).
*/
import { ThemeProvider } from '../view/theme.tsx'
import { describe, expect, test } from 'vitest'
import { ClarifyPrompt } from '../view/prompts/clarifyPrompt.tsx'
import { createSessionStore } from '../logic/store.ts'
import { renderProbe, type RenderProbe } from './lib/render.ts'
const LONG =
'Just analyze for now — give me the implementation plan doc (code-path refs + line numbers, screen-by-screen), no code yet.'
const theme = createSessionStore().state.theme
async function mount(
choices: string[] | null,
onAnswer: (a: string) => void = () => {},
onCancel: () => void = () => {}
): Promise<RenderProbe> {
return renderProbe(
() => (
<ThemeProvider theme={() => theme}>
<ClarifyPrompt
question="How do you want me to proceed?"
choices={choices}
onAnswer={onAnswer}
onCancel={onCancel}
/>
</ThemeProvider>
),
{ height: 24, kittyKeyboard: true, width: 60 }
)
}
describe('ClarifyPrompt (F5/F6)', () => {
test('numbers every option and shows the inline custom-answer input (F5)', async () => {
const h = await mount(['Alpha option', 'Beta option'])
try {
const frame = h.frame()
expect(frame).toContain('1. ')
expect(frame).toContain('2. ')
expect(frame).toContain('Alpha option')
expect(frame).toContain('Beta option')
// the inline custom input is present in the SAME screen (not a separate view)
expect(frame).toContain('or type a custom answer')
} finally {
h.destroy()
}
})
test('a long option WRAPS to a second line rather than clipping (F5)', async () => {
const h = await mount([LONG, 'Short'])
try {
const frame = h.frame()
// a 60-col box can't fit the long option on one line — the head AND the
// tail both appear only because the text wrapped instead of clipping at
// the right edge. (The exact wrap column varies, so assert words that
// land on different lines, not a phrase that straddles the break.)
expect(frame).toContain('Just analyze')
expect(frame).toContain('no code yet')
} finally {
h.destroy()
}
})
test('Down moves the selection; Enter answers the highlighted choice (F6)', async () => {
let answered: string | undefined
const h = await mount(['Alpha option', 'Beta option'], a => (answered = a))
try {
h.keys.pressArrow('down') // 0 → 1 (Beta)
await h.settle()
h.keys.pressEnter()
await h.settle()
expect(answered).toBe('Beta option')
} finally {
h.destroy()
}
})
test('Down past the last choice lands on the custom input; Enter sends typed text', async () => {
let answered: string | undefined
const h = await mount(['Only choice'], a => (answered = a))
try {
h.keys.pressArrow('down') // choice 0 → custom input (index 1)
await h.settle()
await h.keys.typeText('my custom reply')
await h.settle()
h.keys.pressEnter()
await h.settle()
expect(answered).toBe('my custom reply')
} finally {
h.destroy()
}
})
test('no choices → the input is the only control and is focused', async () => {
let answered: string | undefined
const h = await mount(null, a => (answered = a))
try {
expect(h.frame()).toContain('Type your answer')
await h.keys.typeText('freeform')
await h.settle()
h.keys.pressEnter()
await h.settle()
expect(answered).toBe('freeform')
} finally {
h.destroy()
}
})
test('Esc cancels', async () => {
let cancelled = false
const h = await mount(
['A', 'B'],
() => {},
() => (cancelled = true)
)
try {
h.keys.pressEscape()
await h.settle()
expect(cancelled).toBe(true)
} finally {
h.destroy()
}
})
})

View file

@ -50,7 +50,7 @@
* the composer remounts+refocuses whenever an overlay closes).
*/
import { SyntaxStyle, type PasteEvent, type TextareaRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { useKeyboard, useRenderer } from '@opentui/solid'
import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
import { MENU_MAX, routeMenuKey } from '../logic/completionMenu.ts'
@ -299,6 +299,51 @@ export function Composer(props: {
submitting = false
}
/** Shared paste handling for both the focused textarea (native insert covers
* small pastes) and the GLOBAL renderer paste (item: F4 when the composer
* lost focus, e.g. the transcript scrollbox grabbed it, the textarea never
* sees the paste; we focus it and insert the bytes ourselves). `native` is
* true when the textarea will auto-insert a small paste (focused path); on
* the global path it's false, so we insert small pastes manually. Returns
* whether the paste was consumed (caller preventDefault's accordingly). */
const applyPaste = (text: string, native: boolean): boolean => {
// An empty bracketed paste = an image-only clipboard (item 1) — read + attach it.
if (text.trim() === '') {
props.onImagePaste?.()
return true
}
// A large paste becomes a compact `[Pasted text #N +M lines]` chip instead
// of flooding the input; the real text is expanded back on submit.
if (props.pasteStore && shouldPlaceholder(text)) {
ta?.insertText(props.pasteStore.add(text))
return true
}
// Small paste: the focused textarea inserts it natively; the global path
// (unfocused) must insert it itself.
if (!native) {
ta?.insertText(text)
return true
}
return false
}
// F4: a paste while the composer is UNFOCUSED still lands. Paste is a
// renderer-level event (not routed only to the focused renderable), so a
// global listener focuses the textarea and applies the bytes. Guarded on
// `!ta.focused` so the focused path stays the textarea's own onPaste (no
// double insert); skipped while a blocking prompt/overlay owns the screen
// (the composer is unmounted then anyway).
const renderer = useRenderer()
onMount(() => {
const onGlobalPaste = (e: PasteEvent) => {
if (!ta || ta.focused || ta.isDestroyed) return
ta.focus()
const consumed = applyPaste(new TextDecoder().decode(e.bytes), false)
if (consumed) e.preventDefault()
}
renderer.keyInput.on('paste', onGlobalPaste)
onCleanup(() => renderer.keyInput.off('paste', onGlobalPaste))
})
/** Refresh the line-indicator signals from the textarea (best-effort). */
const syncCursorLine = () => {
if (!ta || ta.isDestroyed) return
@ -506,21 +551,9 @@ export function Composer(props: {
onMouseDown={() => ta?.focus()}
onSubmit={submit}
onPaste={(e: PasteEvent) => {
const text = new TextDecoder().decode(e.bytes)
// An empty bracketed paste = an image-only clipboard (item 1) — read + attach it.
if (text.trim() === '') {
e.preventDefault()
props.onImagePaste?.()
return
}
// A large paste becomes a compact `[Pasted text #N +M lines]` chip instead
// of flooding the input; the real text is expanded back on submit.
if (props.pasteStore && shouldPlaceholder(text)) {
e.preventDefault()
ta?.insertText(props.pasteStore.add(text))
return
}
// small pastes fall through to the textarea's native insert
// Focused path: the textarea natively inserts small pastes, so
// applyPaste(native=true) only consumes image/large pastes.
if (applyPaste(new TextDecoder().decode(e.bytes), true)) e.preventDefault()
}}
onCursorChange={() => syncCursorLine()}
onContentChange={() => {