diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 00381179018..41c46f94cd4 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -30,6 +30,7 @@ import { getLog } from '../boundary/log.ts' import { acquireRenderer } from '../boundary/renderer.ts' import { makeAppLayer } from '../boundary/runtime.ts' import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts' +import { createPasteStore } from '../logic/pastes.ts' import { mapResumeHistory, mapSessionList } from '../logic/resume.ts' import { dispatchSlash, mapCompletions, planCompletion, readReplaceFrom, type SlashContext } from '../logic/slash.ts' import { createSessionStore, type SessionStore } from '../logic/store.ts' @@ -184,6 +185,11 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { persist: dirHistoryPersister(historyCwd) }) + // Pasted-text store — created ONCE here so it survives the composer + // remounting (overlay open/close); a per-composer store would lose a + // pending `[Pasted text #N]` mid-compose and submit would send it literally. + const pasteStore = createPasteStore() + // Contact point #2: boundary pushes decoded events into the Solid store. const gateway = yield* GatewayService yield* gateway.subscribe(event => store.apply(event)) @@ -388,6 +394,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { sessionId={() => gateway.sessionId()} history={history} onImagePaste={onImagePaste} + pasteStore={pasteStore} /> ), diff --git a/ui-tui-opentui-v2/src/logic/pastes.ts b/ui-tui-opentui-v2/src/logic/pastes.ts new file mode 100644 index 00000000000..1a9c4ff0677 --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/pastes.ts @@ -0,0 +1,50 @@ +/** + * Pasted-text placeholders (free-code's model). A large paste isn't dumped raw + * into the composer — instead a compact `[Pasted text #N +M lines]` chip is shown + * and the real content is held in a Map, then expanded back on submit. Pure + no + * OpenTUI imports → trivially unit-testable. + * + * The store is created ONCE per session (entry) and passed to the Composer, so it + * survives the composer remounting when overlays open/close (a per-composer store + * would lose a pending paste mid-compose). + */ + +export interface PasteStore { + /** Register a pasted block; returns the placeholder to insert into the input. */ + add(text: string): string + /** Replace every `[Pasted text #N …]` placeholder with its stored content. */ + expand(input: string): string + /** Drop all stored pastes (call after a successful submit). */ + clear(): void +} + +// Matches `[Pasted text #12]` and `[Pasted text #12 +34 lines]`. The id is the key. +const REF = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g + +export function createPasteStore(): PasteStore { + const map = new Map() + let seq = 0 + return { + add(text) { + const id = ++seq + map.set(id, text) + const lines = text.split('\n').length + return lines > 1 ? `[Pasted text #${id} +${lines} lines]` : `[Pasted text #${id}]` + }, + // String.replace(/g) is a SINGLE left-to-right pass over the ORIGINAL string, + // so content inserted for one ref is never re-scanned for another ref — + // a pasted block that itself contains `[Pasted text #k]` is safe. + expand(input) { + return (input ?? '').replace(REF, (m, id: string) => map.get(Number(id)) ?? m) + }, + clear() { + map.clear() + seq = 0 + } + } +} + +/** A paste big enough to placeholder rather than inline (conservative thresholds). */ +export function shouldPlaceholder(text: string): boolean { + return text.split('\n').length >= 4 || text.length > 400 +} diff --git a/ui-tui-opentui-v2/src/test/pastes.test.ts b/ui-tui-opentui-v2/src/test/pastes.test.ts new file mode 100644 index 00000000000..fd93c5cc3b9 --- /dev/null +++ b/ui-tui-opentui-v2/src/test/pastes.test.ts @@ -0,0 +1,53 @@ +/** + * Pasted-text store test — add returns a placeholder, expand restores the real + * content, multiple pastes round-trip, unknown refs pass through, single-pass + * replace keeps a self-referential paste safe. (input polish.) + */ +import { describe, expect, test } from 'bun:test' + +import { createPasteStore, shouldPlaceholder } from '../logic/pastes.ts' + +describe('createPasteStore', () => { + test('add returns a numbered placeholder with the line count', () => { + const s = createPasteStore() + expect(s.add('a\nb\nc')).toBe('[Pasted text #1 +3 lines]') + expect(s.add('single line')).toBe('[Pasted text #2]') // 1 line → no "+N lines" + }) + + test('expand restores the real content for each ref', () => { + const s = createPasteStore() + const p1 = s.add('FIRST\nblock') + const p2 = s.add('SECOND') + const input = `before ${p1} middle ${p2} after` + expect(s.expand(input)).toBe('before FIRST\nblock middle SECOND after') + }) + + test('unknown ref is left as-is (e.g. user typed it, or it was cleared)', () => { + const s = createPasteStore() + expect(s.expand('look [Pasted text #99] here')).toBe('look [Pasted text #99] here') + }) + + test('single-pass replace: a pasted block containing a ref literal is NOT re-expanded', () => { + const s = createPasteStore() + const p1 = s.add('code with [Pasted text #2] inside') + s.add('SHOULD-NOT-APPEAR') + // expanding the input replaces #1 with its content; the #2 inside that content + // is not re-scanned, so SHOULD-NOT-APPEAR never leaks in. + expect(s.expand(`x ${p1}`)).toBe('x code with [Pasted text #2] inside') + }) + + test('clear drops stored pastes and resets ids', () => { + const s = createPasteStore() + const p = s.add('gone') + s.clear() + expect(s.expand(p)).toBe(p) // no longer expandable + expect(s.add('fresh')).toBe('[Pasted text #1]') // seq reset + }) + + test('shouldPlaceholder: ≥4 lines OR >400 chars', () => { + expect(shouldPlaceholder('a\nb\nc\nd')).toBe(true) // 4 lines + expect(shouldPlaceholder('a\nb\nc')).toBe(false) // 3 lines + expect(shouldPlaceholder('x'.repeat(401))).toBe(true) // long + expect(shouldPlaceholder('short')).toBe(false) + }) +}) diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 0780ab4318b..3238b9c2269 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -16,6 +16,7 @@ import { Match, Switch } from 'solid-js' import type { PromptHistory } from '../logic/history.ts' +import type { PasteStore } from '../logic/pastes.ts' import type { SessionStore } from '../logic/store.ts' import { Composer } from './composer.tsx' import { DimensionsProvider } from './dimensions.tsx' @@ -39,6 +40,7 @@ export interface AppProps { readonly sessionId?: () => string | undefined readonly history?: PromptHistory readonly onImagePaste?: () => void + readonly pasteStore?: PasteStore } const NOOP = () => {} @@ -97,6 +99,7 @@ export function App(props: AppProps) { onDismiss={() => props.store.clearCompletions()} history={props.history} onImagePaste={props.onImagePaste} + pasteStore={props.pasteStore} /> } > diff --git a/ui-tui-opentui-v2/src/view/composer.tsx b/ui-tui-opentui-v2/src/view/composer.tsx index 06e1421b714..6ab8033afff 100644 --- a/ui-tui-opentui-v2/src/view/composer.tsx +++ b/ui-tui-opentui-v2/src/view/composer.tsx @@ -25,6 +25,7 @@ import { For, onMount, Show } from 'solid-js' import type { CompletionItem } from '../logic/store.ts' import type { PromptHistory } from '../logic/history.ts' +import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts' import { useDimensions } from './dimensions.tsx' import { useTheme } from './theme.tsx' @@ -82,6 +83,7 @@ export function Composer(props: { onDismiss?: (() => void) | undefined history?: PromptHistory | undefined onImagePaste?: (() => void) | undefined + pasteStore?: PasteStore | undefined }) { const theme = useTheme() const dims = useDimensions() @@ -101,12 +103,15 @@ export function Composer(props: { const submit = () => { if (submitting || !ta) return - const text = ta.plainText.trim() + // Expand any `[Pasted text #N]` placeholders back to their full content before + // sending (item: pasted-text). No-op when nothing was placeheld. + const text = (props.pasteStore?.expand(ta.plainText) ?? ta.plainText).trim() if (!text) return submitting = true props.onSubmit(text) props.history?.push(text) ta.clear() + props.pasteStore?.clear() props.onDismiss?.() submitting = false } @@ -206,12 +211,21 @@ export function Composer(props: { onMouseDown={() => ta?.focus()} onSubmit={submit} onPaste={(e: PasteEvent) => { - // An empty bracketed paste = an image-only clipboard (item 1) — read + - // attach it. Text pastes fall through to the textarea's native insert. - if (new TextDecoder().decode(e.bytes).trim() === '') { + 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 }} onContentChange={() => props.onType?.(ta?.plainText ?? '')} />