mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
5268027e6b
commit
ef9232a2f7
3 changed files with 183 additions and 16 deletions
134
ui-opentui/src/test/clarifyPrompt.test.tsx
Normal file
134
ui-opentui/src/test/clarifyPrompt.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue