From 51f3454e77c94506e481c661066fd6079d4dba99 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Wed, 23 Sep 2026 13:26:36 -0700 Subject: [PATCH] =?UTF-8?q?fix(tui):=20echo=20the=20expanded=20paste,=20no?= =?UTF-8?q?t=20the=20[[=20=E2=80=A6=20]]=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collapsed paste was expanded for prompt.submit but the transcript echo kept the composer label. Echo the expanded text (capped CC-style at 10k chars: 2.5k head + '… +N lines …' + 2.5k tail) and estimate row heights on the same truncation. Same bug class fixed along the way: - /retry re-sent the label (lastUserMsg stored the unexpanded text). - busy / no-session / steer-rejected / 'session busy' submits lost the paste once clearIn() dropped the snippets. The queue keeps the LABEL and its snippets are carried until sent, so {!cmd} interpolation never runs over pasted content. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/useSubmissionPasteEcho.test.ts | 214 ++++++++++++++++++ ui-tui/src/app/useComposerState.ts | 2 +- ui-tui/src/app/useSubmission.ts | 98 ++++++-- ui-tui/src/components/messageLine.tsx | 10 +- ui-tui/src/config/limits.ts | 5 + ui-tui/src/domain/messages.ts | 31 ++- ui-tui/src/lib/virtualHeights.ts | 4 +- 7 files changed, 339 insertions(+), 25 deletions(-) create mode 100644 ui-tui/src/__tests__/useSubmissionPasteEcho.test.ts diff --git a/ui-tui/src/__tests__/useSubmissionPasteEcho.test.ts b/ui-tui/src/__tests__/useSubmissionPasteEcho.test.ts new file mode 100644 index 000000000..2d529a27d --- /dev/null +++ b/ui-tui/src/__tests__/useSubmissionPasteEcho.test.ts @@ -0,0 +1,214 @@ +import { PassThrough } from 'stream' + +import { renderSync } from '@clawcodex/ink' +import React from 'react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { ComposerState, PasteSnippet } from '../app/interfaces.js' +import { patchUiState, resetUiState } from '../app/uiStore.js' +import { useSubmission } from '../app/useSubmission.js' +import { truncateUserPrompt } from '../domain/messages.js' +import type { GatewayClient } from '../gatewayClient.js' +import { pasteTokenLabel } from '../lib/text.js' +import type { Msg } from '../types.js' + +afterEach(() => { + resetUiState() +}) + +const PASTED = Array.from({ length: 14 }, (_, i) => `line ${i + 1}`).join('\n') +const LABEL = pasteTokenLabel(PASTED, 14) + +// Mounts useSubmission against stub composer/gateway; `setSnips` re-renders +// with a new `pasteSnips`, standing in for `clearIn()` after a submit. +const mount = (respond: (method: string) => Promise = m => + Promise.resolve(m === 'session.steer' ? { status: 'queued' } : {})) => { + const appended: Msg[] = [] + const requests: { method: string; text?: string }[] = [] + const queue: string[] = [] + let lastUserMsg = '' + let api!: ReturnType + + const gw = { + request: (method: string, params: { text?: string }) => { + requests.push({ method, text: params.text }) + + return respond(method) + } + } as unknown as GatewayClient + + const composerActions = { + clearIn: () => {}, + enqueue: (text: string) => queue.push(text), + pushHistory: () => {} + } + + const Probe = ({ snips }: { snips: PasteSnippet[] }) => { + api = useSubmission({ + appendMessage: msg => appended.push(msg), + composerActions: composerActions as never, + composerRefs: { queueEditRef: { current: null }, queueRef: { current: queue } } as never, + composerState: { pasteSnips: snips } as unknown as ComposerState, + gw, + maybeGoodVibes: () => {}, + setLastUserMsg: v => { + lastUserMsg = v + }, + slashRef: { current: () => false }, + submitRef: { current: () => {} }, + sys: () => {} + }) + + return null + } + + const stdout = new PassThrough() + const stdin = new PassThrough() + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + + const instance = renderSync(React.createElement(Probe, { snips: [{ label: LABEL, text: PASTED }] }), { + patchConsole: false, + stderr: new PassThrough() as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + return { + api: () => api, + appended, + lastUserMsg: () => lastUserMsg, + queue, + requests, + setSnips: (snips: PasteSnippet[]) => instance.rerender(React.createElement(Probe, { snips })), + unmount: () => { + instance.unmount() + instance.cleanup() + } + } +} + +const submitted = (requests: { method: string; text?: string }[]) => + requests.filter(r => r.method === 'prompt.submit').map(r => r.text) + +describe('useSubmission — collapsed paste expansion', () => { + it('echoes the EXPANDED paste to the transcript, not the [[ … ]] label', () => { + patchUiState({ busy: false, sid: 'sid-1' }) + const h = mount() + + h.api().send(`review this: ${LABEL}`) + + expect(submitted(h.requests)).toEqual([`review this: ${PASTED}`]) + expect(h.appended.map(m => m.text)).toEqual([`review this: ${PASTED}`]) + h.unmount() + }) + + it('records the expanded text for /retry, which re-sends after the snippets are cleared', () => { + patchUiState({ busy: false, sid: 'sid-1' }) + const h = mount() + + h.api().send(`review this: ${LABEL}`) + h.setSnips([]) + h.api().send(h.lastUserMsg()) + + expect(submitted(h.requests)).toEqual([`review this: ${PASTED}`, `review this: ${PASTED}`]) + expect(h.appended.map(m => m.text)).toEqual([`review this: ${PASTED}`, `review this: ${PASTED}`]) + h.unmount() + }) + + it('expands a paste queued while busy once the queue drains after clearIn', () => { + patchUiState({ busy: true, busyInputMode: 'queue', sid: 'sid-1' }) + const h = mount() + + h.api().dispatchSubmission(`review this: ${LABEL}`) + // The label (not the paste) is queued, so `{!cmd}` interpolation can + // never run over pasted content. + expect(h.queue).toEqual([`review this: ${LABEL}`]) + + h.setSnips([]) + patchUiState({ busy: false }) + h.api().sendQueued(h.queue.shift()!) + + expect(submitted(h.requests)).toEqual([`review this: ${PASTED}`]) + expect(h.appended.map(m => m.text)).toEqual([`review this: ${PASTED}`]) + h.unmount() + }) + + it('expands the paste on the steer path', () => { + patchUiState({ busy: true, busyInputMode: 'steer', sid: 'sid-1' }) + const h = mount() + + h.api().dispatchSubmission(`also: ${LABEL}`) + + expect(h.requests.filter(r => r.method === 'session.steer').map(r => r.text)).toEqual([`also: ${PASTED}`]) + h.unmount() + }) +}) + +describe('useSubmission — paste fallbacks queue the label, not the paste', () => { + const flush = () => new Promise(r => setTimeout(r, 0)) + + it('keeps the snippet when steer is rejected, and expands it when the queue drains', async () => { + patchUiState({ busy: true, busyInputMode: 'steer', sid: 'sid-1' }) + const h = mount(m => Promise.resolve(m === 'session.steer' ? { status: 'rejected' } : {})) + + h.api().dispatchSubmission(`also: ${LABEL}`) + await flush() + expect(h.queue).toEqual([`also: ${LABEL}`]) + + h.setSnips([]) + patchUiState({ busy: false }) + h.api().sendQueued(h.queue.shift()!) + + expect(submitted(h.requests)).toEqual([`also: ${PASTED}`]) + h.unmount() + }) + + it('re-queues the label when prompt.submit reports session busy', async () => { + patchUiState({ busy: false, sid: 'sid-1' }) + let first = true + + const h = mount(m => { + if (m === 'prompt.submit' && first) { + first = false + + return Promise.reject(new Error('session busy')) + } + + return Promise.resolve({}) + }) + + h.api().send(`review this: ${LABEL}`) + await flush() + expect(h.queue).toEqual([`review this: ${LABEL}`]) + + h.setSnips([]) + h.api().sendQueued(h.queue.shift()!) + + expect(submitted(h.requests)).toEqual([`review this: ${PASTED}`, `review this: ${PASTED}`]) + h.unmount() + }) +}) + +describe('truncateUserPrompt', () => { + it('leaves prompts up to 10k chars intact', () => { + const text = 'x'.repeat(10_000) + + expect(truncateUserPrompt(text)).toBe(text) + }) + + it('truncates from 10,001 chars', () => { + const out = truncateUserPrompt('x'.repeat(10_001)) + + expect(out).toBe(`${'x'.repeat(2_500)}\n… +0 lines …\n${'x'.repeat(2_500)}`) + }) + + it('keeps head + tail of huge prompts with a hidden-line count', () => { + // 2000 rows × 9 chars − 1 = 17,999 chars; 277 newlines in each of the + // head and tail, 1999 in total → 1722 after the head − 277 in the tail. + const text = Array.from({ length: 2_000 }, (_, i) => `row ${String(i).padStart(4, '0')}`).join('\n') + const out = truncateUserPrompt(text) + + expect(out).toBe(`${text.slice(0, 2_500)}\n… +1445 lines …\n${text.slice(-2_500)}`) + }) +}) diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 623a52064..009237036 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -26,7 +26,7 @@ import { getUiState } from './uiStore.js' const PASTE_SNIP_MAX_COUNT = 32 const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024 -const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => { +export const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => { let total = 0 const out: PasteSnippet[] = [] diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index e16a00fab..ca756668f 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -18,22 +18,38 @@ import type { Msg } from '../types.js' import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js' import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' -import { looksLikeDroppedPath } from './useComposerState.js' +import { looksLikeDroppedPath, trimSnips } from './useComposerState.js' const DOUBLE_ENTER_MS = 450 const SESSION_BUSY_RE = /session busy|waiting for model response/i const isSessionBusyError = (e: unknown) => e instanceof Error && SESSION_BUSY_RE.test(e.message) +// Returns the expander plus the snippets it consumed, so carried snippets +// (see `carriedSnips`) can be dropped once their label has been sent. const expandSnips = (snips: PasteSnippet[]) => { - const byLabel = new Map() + const byLabel = new Map() + const used = new Set() - for (const { label, text } of snips) { - const hit = byLabel.get(label) - hit ? hit.push(text) : byLabel.set(label, [text]) + for (const snip of snips) { + const hit = byLabel.get(snip.label) + hit ? hit.push(snip) : byLabel.set(snip.label, [snip]) } - return (value: string) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok) + const expand = (value: string) => + value.replace(PASTE_SNIPPET_RE, tok => { + const snip = byLabel.get(tok)?.shift() + + if (!snip) { + return tok + } + + used.add(snip) + + return snip.text + }) + + return { expand, used } } const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => @@ -54,6 +70,12 @@ export function useSubmission(opts: UseSubmissionOptions) { } = opts const lastEmptyAt = useRef(0) + // Snippets whose labels went into the queue (busy / no-session submits). + // `clearIn()` empties `pasteSnips` right after enqueueing, so without these + // the label would reach the model — and the transcript — unexpanded once + // the queue drains. The label stays in the queued text (not the expansion) + // so `{!cmd}` interpolation never runs over pasted content. + const carriedSnips = useRef([]) const typingIdleTimer = useRef | null>(null) useEffect(() => { @@ -87,10 +109,20 @@ export function useSubmission(opts: UseSubmissionOptions) { const send = useCallback( (text: string, showUserMessage = true) => { - const expand = expandSnips(composerState.pasteSnips) + const { expand: expandOnce, used } = expandSnips([...composerState.pasteSnips, ...carriedSnips.current]) + + const expand = (value: string) => { + const out = expandOnce(value) + carriedSnips.current = carriedSnips.current.filter(s => !used.has(s)) - const startSubmit = (displayText: string, submitText: string, showUserMessage = true) => { + return out + } + + // The transcript echo shows the EXPANDED text, as CC does: the collapsed + // `[[ … [N lines] … ]]` label is a composer affordance only. + const startSubmit = (rawText: string, showUserMessage = true) => { const sid = getUiState().sid + const submitText = expand(rawText) if (!sid) { return sys('session not ready yet') @@ -98,10 +130,12 @@ export function useSubmission(opts: UseSubmissionOptions) { turnController.clearStatusTimer() maybeGoodVibes(submitText) - setLastUserMsg(text) + // /retry re-sends this after `clearIn()` has dropped the snippets, so + // it must be the expanded text or the label reaches the model. + setLastUserMsg(submitText) if (showUserMessage) { - appendMessage({ role: 'user', text: displayText }) + appendMessage({ role: 'user', text: submitText }) } patchUiState({ busy: true, status: 'running…' }) @@ -110,7 +144,11 @@ export function useSubmission(opts: UseSubmissionOptions) { gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { if (isSessionBusyError(e)) { - composerActions.enqueue(submitText) + // Queue the LABEL and re-carry its snippets, as the busy path + // does: queued text goes through `{!cmd}` interpolation, which + // must never run over pasted content. + carriedSnips.current = trimSnips([...carriedSnips.current, ...used]) + composerActions.enqueue(rawText) patchUiState({ busy: true, status: 'queued for next turn' }) return sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`) @@ -137,13 +175,13 @@ export function useSubmission(opts: UseSubmissionOptions) { // The backend applies the same rule (image_paste.looks_like_dropped_path); // the two are deliberate mirrors, so keep them in sync. if (!looksLikeDroppedPath(text)) { - return startSubmit(text, expand(text), showUserMessage) + return startSubmit(text, showUserMessage) } gw.request('input.detect_drop', { session_id: sid, text }) .then(r => { if (!r?.matched) { - return startSubmit(text, expand(text), showUserMessage) + return startSubmit(text, showUserMessage) } if (r.is_image) { @@ -152,9 +190,9 @@ export function useSubmission(opts: UseSubmissionOptions) { turnController.pushActivity(`detected file: ${r.name}`) } - startSubmit(r.text || text, expand(r.text || text), showUserMessage) + startSubmit(r.text || text, showUserMessage) }) - .catch(() => startSubmit(text, expand(text), showUserMessage)) + .catch(() => startSubmit(text, showUserMessage)) }, [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] ) @@ -261,13 +299,20 @@ export function useSubmission(opts: UseSubmissionOptions) { } if (mode === 'steer' && live.sid) { - gw.request('session.steer', { session_id: live.sid, text: full }) + // Steer bypasses `send`, so expand here; the snippets are cleared by + // the time a fallback enqueue would drain, hence the carry below. + const { expand, used } = expandSnips([...composerState.pasteSnips, ...carriedSnips.current]) + const text = expand(full) + + gw.request('session.steer', { session_id: live.sid, text }) .then(raw => { const r = asRpcResult(raw) if (r?.status !== 'queued') { - fallback('steer rejected — message queued for next turn') + return fallback('steer rejected — message queued for next turn') } + + carriedSnips.current = carriedSnips.current.filter(s => !used.has(s)) }) .catch(() => fallback('steer failed — message queued for next turn')) @@ -281,7 +326,7 @@ export function useSubmission(opts: UseSubmissionOptions) { turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: true }) } }, - [appendMessage, composerActions, composerRefs, gw, sys] + [appendMessage, composerActions, composerRefs, composerState.pasteSnips, gw, sys] ) const dispatchSubmission = useCallback( @@ -307,6 +352,10 @@ export function useSubmission(opts: UseSubmissionOptions) { const live = getUiState() + if (!live.sid || live.busy) { + carriedSnips.current = trimSnips([...carriedSnips.current, ...composerState.pasteSnips]) + } + if (!live.sid) { composerActions.pushHistory(full) composerActions.enqueue(full) @@ -358,7 +407,18 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef] + [ + appendMessage, + composerActions, + composerRefs, + composerState.pasteSnips, + handleBusyInput, + interpolate, + send, + sendQueued, + shellExec, + slashRef + ] ) const submit = useCallback( diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 7a18ca83c..7fe2e1c69 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,11 +1,11 @@ import { Ansi, Box, NoSelect, Text } from '@clawcodex/ink' -import { memo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { TERMUX_TUI_MODE } from '../config/env.js' import { LONG_MSG } from '../config/limits.js' import { hasLeadGap } from '../domain/blockLayout.js' import { sectionMode } from '../domain/details.js' -import { userDisplay } from '../domain/messages.js' +import { truncateUserPrompt, userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js' import { @@ -63,6 +63,10 @@ export const MessageLine = memo(function MessageLine({ // Collapse toggle for long system messages const systemIsLong = msg.role === 'system' && msg.text.length > SYSTEM_COLLAPSE_CHARS const [systemOpen, setSystemOpen] = useState(false) + // Memoised on text alone: the row re-renders on cols/theme changes too, and + // the cap walks the whole (possibly multi-MB) prompt. Hooks stay above the + // early returns below. + const userText = useMemo(() => (msg.role === 'user' ? truncateUserPrompt(msg.text) : msg.text), [msg.role, msg.text]) if (msg.kind === 'trail' && msg.todos?.length) { return @@ -246,7 +250,7 @@ export const MessageLine = memo(function MessageLine({ ) } - return {msg.text} + return {userText} })() // Diff segments (emitted by pushInlineDiffSegment between narration diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts index 72d637810..460cc0f8d 100644 --- a/ui-tui/src/config/limits.ts +++ b/ui-tui/src/config/limits.ts @@ -20,6 +20,11 @@ export const VERBOSE_TRAIL_MAX_CHARS = 16_000 export const VERBOSE_TRAIL_MAX_LINES = 200 export const LONG_MSG = 300 +// User-prompt echo cap (CC UserPromptMessage): past MAX, show head + tail so +// the question at the end of a big paste stays visible. +export const USER_MSG_MAX_DISPLAY_CHARS = 10_000 +export const USER_MSG_TRUNCATE_HEAD_CHARS = 2_500 +export const USER_MSG_TRUNCATE_TAIL_CHARS = 2_500 export const MAX_HISTORY = 800 export const THINKING_COT_MAX = 160 diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 99df7aaa6..707e7a8b4 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -1,4 +1,9 @@ -import { LONG_MSG } from '../config/limits.js' +import { + LONG_MSG, + USER_MSG_MAX_DISPLAY_CHARS, + USER_MSG_TRUNCATE_HEAD_CHARS, + USER_MSG_TRUNCATE_TAIL_CHARS +} from '../config/limits.js' import { buildToolTrailLine, fmtK } from '../lib/text.js' import type { Msg, SessionInfo } from '../types.js' @@ -48,6 +53,30 @@ export const userDisplay = (text: string) => { return `${prefix || '(message)'} [long message]` } +// indexOf walk, not split: the input can be a multi-megabyte paste. +const countNewlines = (text: string, start = 0) => { + let n = 0 + + for (let i = text.indexOf('\n', start); i !== -1; i = text.indexOf('\n', i + 1)) { + n++ + } + + return n +} + +/** CC UserPromptMessage cap: huge prompts render head + `… +N lines …` + tail. */ +export const truncateUserPrompt = (text: string) => { + if (text.length <= USER_MSG_MAX_DISPLAY_CHARS) { + return text + } + + const head = text.slice(0, USER_MSG_TRUNCATE_HEAD_CHARS) + const tail = text.slice(-USER_MSG_TRUNCATE_TAIL_CHARS) + const hiddenLines = countNewlines(text, USER_MSG_TRUNCATE_HEAD_CHARS) - countNewlines(tail) + + return `${head}\n… +${hiddenLines} lines …\n${tail}` +} + export const toTranscriptMessages = (rows: unknown): Msg[] => { if (!Array.isArray(rows)) { return [] diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 666b9d73e..39637d4b8 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -1,4 +1,5 @@ import { TERMUX_TUI_MODE } from '../config/env.js' +import { truncateUserPrompt } from '../domain/messages.js' import { briefCallOfTrailLine, briefRuns, briefText, countBriefTools } from '../domain/toolBrief.js' import type { Msg } from '../types.js' @@ -212,7 +213,8 @@ export const estimatedMsgHeight = ( } const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE) - const text = msg.text + // Match the render: user prompts over the cap paint head + marker + tail. + const text = msg.role === 'user' ? truncateUserPrompt(msg.text) : msg.text // A `trail` block paints no text row at all (MessageLine hands it straight // to ToolTrail), so it must not be charged the one-row floor every prose // block gets — that alone doubled the estimate for a one-row brief.