feat(tui): one token type for everything deferred in the composer

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 23:42:43 -05:00
parent 5d6aae02bf
commit fead8c8d6a
4 changed files with 179 additions and 45 deletions

View file

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

View file

@ -7,7 +7,6 @@ import type {
BillingCardInfo,
BillingMutationResponse,
BillingStateResponse,
ImageAttachResponse,
SessionCloseResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
@ -364,6 +363,10 @@ export interface ComposerPasteResult {
export type MaybePromise<T> = Promise<T> | 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<number>
setComposerTokens: StateSetter<ComposerToken[]>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setPasteSnips: StateSetter<PasteSnippet[]>
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<null | number>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
tokensRef: MutableRefObject<ComposerToken[]>
}
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> | 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<void>
paste: (quiet?: boolean) => void
queueRef: MutableRefObject<string[]>
selection: SelectionApi
setInput: StateSetter<string>
@ -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 }

View file

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

View file

@ -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<string, ComposerToken[]>()
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()
}