From ef9232a2f7d17a339bcb88368060d8ee7d47a1d8 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 13 Jun 2026 19:14:25 +0530 Subject: [PATCH] opentui(v6): paste-while-unfocused + clarify prompt rewrite (F4/F5/F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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. --- ui-opentui/src/test/clarifyPrompt.test.tsx | 134 ++++++++++++++++++ ui-opentui/src/view/composer.tsx | 65 ++++++--- ui-opentui/src/view/prompts/clarifyPrompt.tsx | Bin 2916 -> 5541 bytes 3 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 ui-opentui/src/test/clarifyPrompt.test.tsx 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 d292a8f7ecb77e813a86034f901d8023dbf268bc..906b9718ccd80478417096eb7dd7cc0181e90934 100644 GIT binary patch literal 5541 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