opentui(input): "Pasted text" placeholder for large pastes

Large bracketed pastes no longer flood the composer. On paste, if the text is
≥4 lines or >400 chars, insert a compact `[Pasted text #N +M lines]` chip and
hold the real content in a PasteStore; on submit, expand the chip back to the
full text before sending (free-code model). Single-pass String.replace keeps a
pasted block that itself contains a `[Pasted text #k]` literal safe.

The store is created ONCE in main.tsx and passed App→Composer (NOT per-composer)
so it survives the composer remounting on overlay open/close — a per-composer
store would lose a pending paste mid-compose. +6 unit tests (91 pass). Verified
live: paste 10 lines → chip; submit → transcript shows the full expanded code;
composer cleared.
This commit is contained in:
alt-glitch 2026-06-09 06:09:49 +00:00
parent 080440bd9c
commit 46293f618c
5 changed files with 131 additions and 4 deletions

View file

@ -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}
/>
</ThemeProvider>
),

View file

@ -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<number, string>()
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
}

View file

@ -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)
})
})

View file

@ -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}
/>
}
>

View file

@ -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 ?? '')}
/>