From fead8c8d6ad3fa6f422937e97a35936929159a79 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:42:43 -0500 Subject: [PATCH] feat(tui): one token type for everything deferred in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collapsed paste and an attached image are the same idea: a `[[ … ]]` marker sitting in the input line that stands in for a payload resolved at submit. Model both as ComposerToken and give them one expander. Image tokens resolve to nothing — the gateway already holds the file in attached_images — so expandTokens eats an adjacent space to avoid leaving a gap mid-sentence. nextImageIndex never reuses an index after a delete, or two files would collide on one label. --- ui-tui/src/__tests__/attachments.test.ts | 96 ++++++++++++++++++++++++ ui-tui/src/app/interfaces.ts | 34 ++++++--- ui-tui/src/app/useSubmission.test.ts | 34 --------- ui-tui/src/domain/attachments.ts | 60 +++++++++++++++ 4 files changed, 179 insertions(+), 45 deletions(-) create mode 100644 ui-tui/src/__tests__/attachments.test.ts delete mode 100644 ui-tui/src/app/useSubmission.test.ts create mode 100644 ui-tui/src/domain/attachments.ts diff --git a/ui-tui/src/__tests__/attachments.test.ts b/ui-tui/src/__tests__/attachments.test.ts new file mode 100644 index 00000000000..50e5088bcc0 --- /dev/null +++ b/ui-tui/src/__tests__/attachments.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' + +import type { ComposerToken } from '../app/interfaces.js' +import { droppedTokens, expandTokens, imageToken, nextImageIndex } from '../domain/attachments.js' + +const paste = (label: string, text: string): ComposerToken => ({ kind: 'paste', label, text }) + +const image = (index: number, path = `/tmp/img${index}.png`): ComposerToken => ({ + index, + kind: 'image', + label: imageToken(index), + path +}) + +describe('expandTokens (what the agent actually receives)', () => { + it('replaces a collapsed paste label with its full content', () => { + const label = '[[ hello.. [3 lines] .. world ]]' + const expand = expandTokens([paste(label, 'hello\nfoo\nworld')]) + + expect(expand(`here: ${label} done`)).toBe('here: hello\nfoo\nworld done') + }) + + it('is a no-op for already-expanded / token-free text (recall round-trip)', () => { + const expanded = 'hello\nfoo\nworld' + expect(expandTokens([])(expanded)).toBe(expanded) + }) + + it('expands repeated identical labels in submission order', () => { + const label = '[[ x [1 lines] ]]' + const expand = expandTokens([paste(label, 'first'), paste(label, 'second')]) + + expect(expand(`${label} then ${label}`)).toBe('first then second') + }) + + it('leaves an unmatched label intact', () => { + const label = '[[ orphan [2 lines] ]]' + expect(expandTokens([])(label)).toBe(label) + }) + + it('drops an image token from the text — the gateway already holds the file', () => { + const expand = expandTokens([image(1)]) + + expect(expand(`what is in ${imageToken(1)}`)).toBe('what is in') + }) + + it('leaves no double space where an image token sat mid-sentence', () => { + const expand = expandTokens([image(1)]) + + expect(expand(`before ${imageToken(1)} after`)).toBe('before after') + }) + + it('resolves an image-only message to empty text', () => { + expect(expandTokens([image(1)])(imageToken(1))).toBe('') + }) + + it('resolves pastes and images in one pass', () => { + const label = '[[ log.. [9 lines] ]]' + const expand = expandTokens([paste(label, 'stack\ntrace'), image(2)]) + + expect(expand(`${label} and ${imageToken(2)}`)).toBe('stack\ntrace and') + }) +}) + +describe('nextImageIndex (user-facing numbering)', () => { + it('starts at 1', () => { + expect(nextImageIndex([])).toBe(1) + }) + + it('counts past existing images', () => { + expect(nextImageIndex([image(1), image(2)])).toBe(3) + }) + + it('ignores paste tokens', () => { + expect(nextImageIndex([paste('[[ x ]]', 'y')])).toBe(1) + }) + + it('does not reuse an index after an earlier image is deleted', () => { + // [[ Image 1 ]] was erased; the next attach must not become Image 1 again + // or expandTokens would resolve two different files to one label. + expect(nextImageIndex([image(2)])).toBe(3) + }) +}) + +describe('droppedTokens (deleting the token unattaches the thing)', () => { + it('reports an image whose token was erased from the text', () => { + expect(droppedTokens([image(1)], 'just text now')).toEqual([image(1)]) + }) + + it('reports nothing while the token is still present', () => { + expect(droppedTokens([image(1)], `look at ${imageToken(1)}`)).toEqual([]) + }) + + it('keeps a surviving token when a sibling is erased', () => { + expect(droppedTokens([image(1), image(2)], imageToken(2))).toEqual([image(1)]) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1a446f69e15..3802769e933 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -7,7 +7,6 @@ import type { BillingCardInfo, BillingMutationResponse, BillingStateResponse, - ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, @@ -364,6 +363,10 @@ export interface ComposerPasteResult { export type MaybePromise = Promise | T export interface ComposerActions { + /** Pull an image off the system clipboard in as a token. */ + attachClipboardImage: () => void + /** Attach an image by path in as a token. */ + attachImagePath: (path: string) => void clearIn: () => void dequeue: () => string | undefined enqueue: (text: string) => void @@ -373,12 +376,14 @@ export interface ComposerActions { removeQueue: (index: number) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter + setComposerTokens: StateSetter setHistoryIdx: StateSetter setInput: StateSetter setInputBuf: StateSetter - setPasteSnips: StateSetter setQueueEdit: (index: null | number) => void syncQueue: () => void + /** Reconcile attached payloads against tokens still present in the text. */ + syncTokens: (value: string) => void } export interface ComposerRefs { @@ -387,6 +392,7 @@ export interface ComposerRefs { queueEditRef: MutableRefObject queueRef: MutableRefObject submitRef: MutableRefObject<(value: string) => void> + tokensRef: MutableRefObject } export interface ComposerState { @@ -396,16 +402,15 @@ export interface ComposerState { historyIdx: null | number input: string inputBuf: string[] - pasteSnips: PasteSnippet[] queueEditIdx: null | number queuedDisplay: string[] + tokens: ComposerToken[] } export interface UseComposerStateOptions { gw: GatewayClient - onClipboardPaste: (quiet?: boolean) => Promise | void - onImageAttached?: (info: ImageAttachResponse) => void submitRef: MutableRefObject<(value: string) => void> + sys: (text: string) => void } export interface UseComposerStateResult { @@ -495,10 +500,11 @@ export interface GatewayEventHandlerContext { export interface SlashHandlerContext { composer: { + attachClipboardImage: () => void + attachImagePath: (path: string) => void enqueue: (text: string) => void hasSelection: boolean openEditor: () => Promise - paste: (quiet?: boolean) => void queueRef: MutableRefObject selection: SelectionApi setInput: StateSetter @@ -618,8 +624,14 @@ export interface AppOverlaysProps { pagerPageSize: number } -export interface PasteSnippet { - label: string - path?: string - text: string -} +/** + * A `[[ … ]]` token sitting in the composer text, plus the payload it stands + * for. `paste` tokens expand back into their text at submit; `image` tokens + * are a receipt for a file the gateway already holds, and expand to nothing. + * + * `index` is the user-facing number in `[[ Image 2 ]]`; `path` is the gateway + * path, used to detach the image when its token is deleted. + */ +export type ComposerToken = + | { index: number; kind: 'image'; label: string; path: string; text?: undefined } + | { index?: undefined; kind: 'paste'; label: string; path?: string; text: string } diff --git a/ui-tui/src/app/useSubmission.test.ts b/ui-tui/src/app/useSubmission.test.ts deleted file mode 100644 index 34202104dd4..00000000000 --- a/ui-tui/src/app/useSubmission.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { PasteSnippet } from './interfaces.js' -import { expandSnips } from './useSubmission.js' - -const snip = (label: string, text: string): PasteSnippet => ({ label, text }) - -describe('expandSnips (paste history recall)', () => { - it('replaces a collapsed paste label with its full content', () => { - const label = '[[ hello.. [3 lines] .. world ]]' - const full = `here: ${label} done` - const expand = expandSnips([snip(label, 'hello\nfoo\nworld')]) - - expect(expand(full)).toBe('here: hello\nfoo\nworld done') - }) - - it('is a no-op for already-expanded / label-free text (recall round-trip)', () => { - const expanded = 'hello\nfoo\nworld' - // Re-submitting a recalled history entry has no snips and no labels. - expect(expandSnips([])(expanded)).toBe(expanded) - }) - - it('expands repeated identical labels in submission order', () => { - const label = '[[ x [1 lines] ]]' - const expand = expandSnips([snip(label, 'first'), snip(label, 'second')]) - - expect(expand(`${label} then ${label}`)).toBe('first then second') - }) - - it('leaves an unmatched label intact', () => { - const label = '[[ orphan [2 lines] ]]' - expect(expandSnips([])(label)).toBe(label) - }) -}) diff --git a/ui-tui/src/domain/attachments.ts b/ui-tui/src/domain/attachments.ts new file mode 100644 index 00000000000..7894cf170a0 --- /dev/null +++ b/ui-tui/src/domain/attachments.ts @@ -0,0 +1,60 @@ +import type { ComposerToken } from '../app/interfaces.js' +import { PASTE_SNIPPET_RE } from '../protocol/paste.js' + +/** + * Composer tokens are the ONE way deferred content shows up in the input line: + * a collapsed paste and an attached image both render as `[[ … ]]` sitting in + * the text the user is editing. They are ordinary characters — arrow keys, + * backspace, and selection work on them for free — and they carry their real + * payload out-of-band until submit. + * + * Two consequences the rest of the composer relies on: + * - Deleting the token is how you drop the thing. Nothing else to click. + * - Position in the text is meaningful: the model sees the payload where the + * token sat, not stapled to the front of the turn. + */ +export const imageToken = (index: number) => `[[ Image ${index} ]]` + +/** Highest image token index handed out so far, so a new one never collides. */ +export const nextImageIndex = (tokens: ComposerToken[]) => + tokens.reduce((max, t) => (t.kind === 'image' ? Math.max(max, t.index) : max), 0) + 1 + +/** Tokens whose label is no longer anywhere in the composer text. */ +export const droppedTokens = (tokens: ComposerToken[], value: string) => { + const live = new Set(value.match(PASTE_SNIPPET_RE) ?? []) + + return tokens.filter(t => !live.has(t.label)) +} + +/** + * Resolve every token in `value` to what the agent should actually receive. + * + * Repeated identical labels expand in submission order (left to right), which + * is why this walks matches instead of doing a global replace per token. + * + * An image token expands to nothing: the gateway already holds the file in + * `session.attached_images` and splices the real vision content in at submit. + * The token's job was to show the user where it landed, so it also eats one + * adjacent space to avoid leaving a gap in the middle of a sentence. + */ +export const expandTokens = (tokens: ComposerToken[]) => { + const byLabel = new Map() + + for (const token of tokens) { + const hit = byLabel.get(token.label) + hit ? hit.push(token) : byLabel.set(token.label, [token]) + } + + return (value: string) => + value + .replace(new RegExp(`[ \\t]?(?:${PASTE_SNIPPET_RE.source})`, 'g'), match => { + const token = byLabel.get(match.trimStart())?.shift() + + if (!token) { + return match + } + + return token.kind === 'paste' ? match.slice(0, match.length - token.label.length) + token.text : '' + }) + .trim() +}