mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
5d6aae02bf
commit
fead8c8d6a
4 changed files with 179 additions and 45 deletions
96
ui-tui/src/__tests__/attachments.test.ts
Normal file
96
ui-tui/src/__tests__/attachments.test.ts
Normal 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)])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
60
ui-tui/src/domain/attachments.ts
Normal file
60
ui-tui/src/domain/attachments.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue