diff --git a/ui-opentui/src/test/clarifyPrompt.test.tsx b/ui-opentui/src/test/clarifyPrompt.test.tsx new file mode 100644 index 00000000000..0bfc36f05b2 --- /dev/null +++ b/ui-opentui/src/test/clarifyPrompt.test.tsx @@ -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 { + return renderProbe( + () => ( + theme}> + + + ), + { 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() + } + }) +}) diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index 6ad0e10b9f2..6d0dbc7ed2a 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -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={() => { diff --git a/ui-opentui/src/view/prompts/clarifyPrompt.tsx b/ui-opentui/src/view/prompts/clarifyPrompt.tsx index d292a8f7ecb..906b9718ccd 100644 Binary files a/ui-opentui/src/view/prompts/clarifyPrompt.tsx and b/ui-opentui/src/view/prompts/clarifyPrompt.tsx differ