From 0f53d67ee42b655ce04f82ef8885ae434ec90f1f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 07:09:07 +0000 Subject: [PATCH] opentui(copy): assistant-text extraction helpers --- ui-tui-opentui-v2/src/logic/copy.ts | 36 ++++++++++ ui-tui-opentui-v2/src/test/copy.test.ts | 94 +++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 ui-tui-opentui-v2/src/logic/copy.ts create mode 100644 ui-tui-opentui-v2/src/test/copy.test.ts diff --git a/ui-tui-opentui-v2/src/logic/copy.ts b/ui-tui-opentui-v2/src/logic/copy.ts new file mode 100644 index 00000000000..14abed8e49e --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/copy.ts @@ -0,0 +1,36 @@ +/** + * Assistant-text extraction (the /copy command's pure logic). An assistant turn's + * answer lives in `parts` (the `type:'text'` fragments, concatenated) while live, + * OR in `.text` once settled/resumed. We copy the ANSWER only — reasoning and tool + * parts are excluded. `nthAssistantResponse` indexes newest-first (1-based). + */ +import type { Message } from './store.ts' + +/** The answer text of one message: concat the `text` parts (trimmed) when live, else `.text`. */ +export function messageText(m: Message): string { + if (m.parts && m.parts.length) { + return m.parts + .filter(p => p.type === 'text') + .map(p => p.text) + .join('') + .trim() + } + return m.text +} + +/** Newest-first list of the non-empty answer text for every assistant message. */ +export function assistantResponses(messages: Message[]): string[] { + const out: string[] = [] + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (!m || m.role !== 'assistant') continue + const text = messageText(m) + if (text) out.push(text) + } + return out +} + +/** The n-th newest assistant response (1-based; n=1 → last). `undefined` if out of range. */ +export function nthAssistantResponse(messages: Message[], n: number): string | undefined { + return assistantResponses(messages)[n - 1] +} diff --git a/ui-tui-opentui-v2/src/test/copy.test.ts b/ui-tui-opentui-v2/src/test/copy.test.ts new file mode 100644 index 00000000000..7f06f7f02ca --- /dev/null +++ b/ui-tui-opentui-v2/src/test/copy.test.ts @@ -0,0 +1,94 @@ +/** + * Assistant-text extraction helpers (the /copy command's logic). Pure functions: + * pull the answer text out of a live (parts) or settled (.text) assistant turn, + * excluding reasoning/tool parts; pick the n-th newest assistant response. + */ +import { describe, expect, test } from 'bun:test' + +import { assistantResponses, messageText, nthAssistantResponse } from '../logic/copy.ts' +import type { Message } from '../logic/store.ts' + +describe('messageText', () => { + test('a live parts turn concatenates text parts; excludes reasoning/tool', () => { + const m: Message = { + role: 'assistant', + text: '', + parts: [ + { type: 'reasoning', id: 'p1', text: 'thinking…' }, + { type: 'text', id: 'p2', text: 'Hello' }, + { type: 'tool', id: 't1', name: 'bash', state: 'complete', resultText: 'ran' }, + { type: 'text', id: 'p3', text: ' world' } + ] + } + expect(messageText(m)).toBe('Hello world') + }) + + test('trims surrounding whitespace from concatenated text parts', () => { + const m: Message = { + role: 'assistant', + text: '', + parts: [{ type: 'text', id: 'p1', text: ' spaced ' }] + } + expect(messageText(m)).toBe('spaced') + }) + + test('a settled/resumed turn (no parts) returns .text', () => { + const m: Message = { role: 'assistant', text: 'resumed answer' } + expect(messageText(m)).toBe('resumed answer') + }) + + test('empty parts array falls back to .text', () => { + const m: Message = { role: 'assistant', text: 'flat body', parts: [] } + expect(messageText(m)).toBe('flat body') + }) +}) + +describe('assistantResponses', () => { + test('picks only assistant rows, newest-first, non-empty', () => { + const messages: Message[] = [ + { role: 'system', text: 'welcome' }, + { role: 'user', text: 'hi' }, + { role: 'assistant', text: 'first reply' }, + { role: 'user', text: 'and?' }, + { role: 'assistant', text: '', parts: [{ type: 'text', id: 'p1', text: 'second reply' }] } + ] + expect(assistantResponses(messages)).toEqual(['second reply', 'first reply']) + }) + + test('skips assistant rows that resolve to empty text', () => { + const messages: Message[] = [ + { role: 'assistant', text: 'kept' }, + { role: 'assistant', text: '', parts: [{ type: 'reasoning', id: 'r1', text: 'only thinking' }] } + ] + expect(assistantResponses(messages)).toEqual(['kept']) + }) + + test('empty messages → []', () => { + expect(assistantResponses([])).toEqual([]) + }) +}) + +describe('nthAssistantResponse', () => { + const messages: Message[] = [ + { role: 'assistant', text: 'oldest' }, + { role: 'user', text: 'q' }, + { role: 'assistant', text: 'newest' } + ] + + test('n=1 is the last assistant response', () => { + expect(nthAssistantResponse(messages, 1)).toBe('newest') + }) + + test('n=2 is the previous assistant response', () => { + expect(nthAssistantResponse(messages, 2)).toBe('oldest') + }) + + test('n past the end → undefined', () => { + expect(nthAssistantResponse(messages, 3)).toBeUndefined() + }) + + test('no assistant responses → undefined', () => { + expect(nthAssistantResponse([{ role: 'user', text: 'hi' }], 1)).toBeUndefined() + expect(nthAssistantResponse([], 1)).toBeUndefined() + }) +})