From 8f0e245080eef6cbc5960cf9b97a0570a60ee0dc Mon Sep 17 00:00:00 2001 From: Jonas Romer Date: Sat, 11 Jul 2026 00:10:14 +0200 Subject: [PATCH 1/3] fix(cli): render assistant blocks as a numbered list in the remote transcript view The Happier base system prompt instructs agents to append quick-reply options as an ... XML block. The mobile/web app parses this at markdown render time and shows tappable buttons, and the Gemini backend extracts it at turn end, but the Claude remote-mode terminal transcript (formatClaudeMessageForInk / formatClaudeMessage) printed the raw XML to the user. Move the options parser from backends/gemini/utils to utils (it is no longer Gemini-specific), add formatTextWithOptionsForTerminal, and route assistant text and result summaries through it so terminal surfaces show a readable numbered list instead of raw XML. --- apps/cli/src/backends/gemini/runGemini.ts | 2 +- apps/cli/src/ui/messageFormatter.ts | 5 +- apps/cli/src/ui/messageFormatterInk.test.ts | 51 ++++++++++++ apps/cli/src/ui/messageFormatterInk.ts | 5 +- apps/cli/src/utils/optionsParser.test.ts | 81 +++++++++++++++++++ .../gemini => }/utils/optionsParser.ts | 17 ++++ 6 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 apps/cli/src/ui/messageFormatterInk.test.ts create mode 100644 apps/cli/src/utils/optionsParser.test.ts rename apps/cli/src/{backends/gemini => }/utils/optionsParser.ts (72%) diff --git a/apps/cli/src/backends/gemini/runGemini.ts b/apps/cli/src/backends/gemini/runGemini.ts index 0f9529ebba..5d3d2f9862 100644 --- a/apps/cli/src/backends/gemini/runGemini.ts +++ b/apps/cli/src/backends/gemini/runGemini.ts @@ -77,7 +77,7 @@ import { parseOptionsFromText, hasIncompleteOptions, formatOptionsXml, -} from '@/backends/gemini/utils/optionsParser'; +} from '@/utils/optionsParser'; import { ConversationHistory } from '@/backends/gemini/utils/conversationHistory'; import { createGeminiBackendMessageHandler } from '@/backends/gemini/runtime/createGeminiBackendMessageHandler'; import { reportGeminiConnectedServiceRuntimeAuthFailureBestEffort } from '@/backends/gemini/connectedServices/surfaceGeminiConnectedServiceRuntimeAuthFailure'; diff --git a/apps/cli/src/ui/messageFormatter.ts b/apps/cli/src/ui/messageFormatter.ts index 903a7a96cd..df367b0123 100644 --- a/apps/cli/src/ui/messageFormatter.ts +++ b/apps/cli/src/ui/messageFormatter.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import type { SDKMessage, SDKAssistantMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from '@/backends/claude/sdk'; +import { formatTextWithOptionsForTerminal } from '@/utils/optionsParser'; import { logger } from './logger'; export type OnAssistantResultCallback = (result: SDKResultMessage) => void | Promise; @@ -76,7 +77,7 @@ export function formatClaudeMessage( // Handle content array (can contain text blocks and tool use blocks) for (const block of assistantMsg.message.content) { if (block.type === 'text') { - console.log(block.text); + console.log(formatTextWithOptionsForTerminal(block.text ?? '')); } else if (block.type === 'tool_use') { console.log(chalk.yellow.bold(`\nšŸ”§ Tool: ${block.name}`)); if (block.input) { @@ -99,7 +100,7 @@ export function formatClaudeMessage( if (resultMsg.subtype === 'success') { if ('result' in resultMsg && resultMsg.result) { console.log(chalk.green.bold('\n✨ Summary:')); - console.log(resultMsg.result); + console.log(formatTextWithOptionsForTerminal(resultMsg.result)); } // Show usage stats diff --git a/apps/cli/src/ui/messageFormatterInk.test.ts b/apps/cli/src/ui/messageFormatterInk.test.ts new file mode 100644 index 0000000000..d85cb72028 --- /dev/null +++ b/apps/cli/src/ui/messageFormatterInk.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import type { SDKAssistantMessage, SDKMessage, SDKResultMessage } from '@/backends/claude/sdk' +import { MessageBuffer } from './ink/messageBuffer' +import { formatClaudeMessageForInk } from './messageFormatterInk' + +function buildAssistantMessage(text: string): SDKMessage { + return { + type: 'assistant', + message: { + content: [{ type: 'text', text }], + }, + } as unknown as SDKAssistantMessage +} + +describe('formatClaudeMessageForInk', () => { + it('renders assistant blocks as a numbered list instead of raw XML', () => { + const messageBuffer = new MessageBuffer() + const text = 'Should I proceed?\n\n\n \n \n' + + formatClaudeMessageForInk(buildAssistantMessage(text), messageBuffer) + + const contents = messageBuffer.getMessages().map((m) => m.content) + expect(contents).toContain('Should I proceed?\n\nOptions:\n 1. Yes, go ahead\n 2. No, stop here') + expect(contents.join('\n')).not.toContain('') + }) + + it('leaves assistant text without options untouched', () => { + const messageBuffer = new MessageBuffer() + const text = 'All done. Nothing to choose here.' + + formatClaudeMessageForInk(buildAssistantMessage(text), messageBuffer) + + const contents = messageBuffer.getMessages().map((m) => m.content) + expect(contents).toContain(text) + }) + + it('renders blocks in the result summary as a numbered list', () => { + const messageBuffer = new MessageBuffer() + const resultMessage = { + type: 'result', + subtype: 'success', + result: 'Pick a next step.\n\n\n\n\n', + } as unknown as SDKResultMessage + + formatClaudeMessageForInk(resultMessage, messageBuffer) + + const contents = messageBuffer.getMessages().map((m) => m.content) + expect(contents).toContain('Pick a next step.\n\nOptions:\n 1. Continue\n 2. Abort') + expect(contents.join('\n')).not.toContain('') + }) +}) diff --git a/apps/cli/src/ui/messageFormatterInk.ts b/apps/cli/src/ui/messageFormatterInk.ts index 4a13bc7b85..fc4dfcf9f2 100644 --- a/apps/cli/src/ui/messageFormatterInk.ts +++ b/apps/cli/src/ui/messageFormatterInk.ts @@ -1,4 +1,5 @@ import type { SDKMessage, SDKAssistantMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from '@/backends/claude/sdk' +import { formatTextWithOptionsForTerminal } from '@/utils/optionsParser' import type { MessageBuffer } from './ink/messageBuffer' import { logger } from './logger' @@ -72,7 +73,7 @@ export function formatClaudeMessageForInk( for (const block of assistantMsg.message.content) { if (block.type === 'text') { - messageBuffer.addMessage(block.text || '', 'assistant') + messageBuffer.addMessage(formatTextWithOptionsForTerminal(block.text || ''), 'assistant') } else if (block.type === 'tool_use') { messageBuffer.addMessage(`šŸ”§ Tool: ${block.name}`, 'tool') if (block.input) { @@ -95,7 +96,7 @@ export function formatClaudeMessageForInk( if (resultMsg.subtype === 'success') { if ('result' in resultMsg && resultMsg.result) { messageBuffer.addMessage('✨ Summary:', 'result') - messageBuffer.addMessage(resultMsg.result || '', 'result') + messageBuffer.addMessage(formatTextWithOptionsForTerminal(resultMsg.result || ''), 'result') } if (resultMsg.usage) { diff --git a/apps/cli/src/utils/optionsParser.test.ts b/apps/cli/src/utils/optionsParser.test.ts new file mode 100644 index 0000000000..aacd50e8c3 --- /dev/null +++ b/apps/cli/src/utils/optionsParser.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + formatOptionsXml, + formatTextWithOptionsForTerminal, + hasIncompleteOptions, + parseOptionsFromText, +} from './optionsParser'; + +describe('parseOptionsFromText', () => { + it('extracts options and returns text without the options block', () => { + const input = 'Which approach do you prefer?\n\n\n \n \n'; + const result = parseOptionsFromText(input); + expect(result.text).toBe('Which approach do you prefer?'); + expect(result.options).toEqual(['Option A', 'Option B']); + }); + + it('returns trimmed text and empty options when no options block exists', () => { + const result = parseOptionsFromText(' Just a plain answer. '); + expect(result.text).toBe('Just a plain answer.'); + expect(result.options).toEqual([]); + }); + + it('matches tags case-insensitively', () => { + const input = 'Pick one:\n\n\n\n'; + const result = parseOptionsFromText(input); + expect(result.text).toBe('Pick one:'); + expect(result.options).toEqual(['Yes', 'No']); + }); + + it('ignores empty option tags', () => { + const input = ''; + const result = parseOptionsFromText(input); + expect(result.options).toEqual(['Keep me']); + }); +}); + +describe('hasIncompleteOptions', () => { + it('detects an opening tag without a closing tag', () => { + expect(hasIncompleteOptions('Question?\n\n')).toBe(true); + }); + + it('returns false for complete blocks and plain text', () => { + expect(hasIncompleteOptions('')).toBe(false); + expect(hasIncompleteOptions('No options here')).toBe(false); + }); +}); + +describe('formatOptionsXml', () => { + it('round-trips options through XML', () => { + const xml = formatOptionsXml(['One', 'Two']); + expect(parseOptionsFromText(xml).options).toEqual(['One', 'Two']); + }); + + it('returns an empty string for no options', () => { + expect(formatOptionsXml([])).toBe(''); + }); +}); + +describe('formatTextWithOptionsForTerminal', () => { + it('replaces the options block with a numbered list', () => { + const input = 'Which approach do you prefer?\n\n\n \n \n'; + const result = formatTextWithOptionsForTerminal(input); + expect(result).toBe('Which approach do you prefer?\n\nOptions:\n 1. Option A\n 2. Option B'); + expect(result).not.toContain(''); + }); + + it('renders a numbered list when the message is options-only', () => { + const input = '\n\n\n'; + expect(formatTextWithOptionsForTerminal(input)).toBe('Options:\n 1. Yes\n 2. No'); + }); + + it('returns text unchanged when there is no options block', () => { + const input = 'Plain answer with spacing\npreserved.'; + expect(formatTextWithOptionsForTerminal(input)).toBe(input); + }); + + it('returns text unchanged for an incomplete options block', () => { + const input = 'Question?\n\n'; + expect(formatTextWithOptionsForTerminal(input)).toBe(input); + }); +}); diff --git a/apps/cli/src/backends/gemini/utils/optionsParser.ts b/apps/cli/src/utils/optionsParser.ts similarity index 72% rename from apps/cli/src/backends/gemini/utils/optionsParser.ts rename to apps/cli/src/utils/optionsParser.ts index bf105dd1f8..111f5d500a 100644 --- a/apps/cli/src/backends/gemini/utils/optionsParser.ts +++ b/apps/cli/src/utils/optionsParser.ts @@ -68,3 +68,20 @@ export function formatOptionsXml(options: string[]): string { return '\n\n' + options.map(opt => ` `).join('\n') + '\n'; } +/** + * Format assistant text for terminal display + * Replaces an ... XML block with a readable numbered list + * so terminal surfaces (which have no tappable option buttons) do not show raw XML. + * + * @param text - The assistant text potentially containing an options XML block + * @returns The text with the options block rendered as a numbered list + */ +export function formatTextWithOptionsForTerminal(text: string): string { + const { text: textWithoutOptions, options } = parseOptionsFromText(text); + if (options.length === 0) { + return text; + } + const numberedList = options.map((option, index) => ` ${index + 1}. ${option}`).join('\n'); + const optionsBlock = `Options:\n${numberedList}`; + return textWithoutOptions.length > 0 ? `${textWithoutOptions}\n\n${optionsBlock}` : optionsBlock; +} From a8e6df262669e6e772751b58f865079a0660069d Mon Sep 17 00:00:00 2001 From: Jonas Romer Date: Sat, 11 Jul 2026 11:59:55 +0200 Subject: [PATCH 2/3] address review: format every options block in a message, not only the first (in-place replacement; empty blocks dropped) --- apps/cli/src/utils/optionsParser.test.ts | 22 ++++++++++++++++++- apps/cli/src/utils/optionsParser.ts | 28 +++++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/utils/optionsParser.test.ts b/apps/cli/src/utils/optionsParser.test.ts index aacd50e8c3..f24cb0997f 100644 --- a/apps/cli/src/utils/optionsParser.test.ts +++ b/apps/cli/src/utils/optionsParser.test.ts @@ -74,8 +74,28 @@ describe('formatTextWithOptionsForTerminal', () => { expect(formatTextWithOptionsForTerminal(input)).toBe(input); }); + it('formats every options block when a message contains more than one', () => { + const text = 'First question:\n\n\n\n\nSecond question:\n\n\n'; + const result = formatTextWithOptionsForTerminal(text); + expect(result).not.toContain(''); + expect(result).toContain(' 1. A'); + expect(result).toContain(' 2. B'); + expect(result).toContain('First question:'); + expect(result).toContain('Second question:'); + expect(result.match(/Options:/g)?.length).toBe(2); + expect(result).toContain(' 1. C'); + }); + + it('drops empty options blocks entirely', () => { + const result = formatTextWithOptionsForTerminal('Before\n\n\nAfter'); + expect(result).not.toContain(''); + expect(result).toContain('Before'); + expect(result).toContain('After'); + }); + it('returns text unchanged for an incomplete options block', () => { const input = 'Question?\n\n'; expect(formatTextWithOptionsForTerminal(input)).toBe(input); - }); + +}); }); diff --git a/apps/cli/src/utils/optionsParser.ts b/apps/cli/src/utils/optionsParser.ts index 111f5d500a..291de1c873 100644 --- a/apps/cli/src/utils/optionsParser.ts +++ b/apps/cli/src/utils/optionsParser.ts @@ -77,11 +77,29 @@ export function formatOptionsXml(options: string[]): string { * @returns The text with the options block rendered as a numbered list */ export function formatTextWithOptionsForTerminal(text: string): string { - const { text: textWithoutOptions, options } = parseOptionsFromText(text); - if (options.length === 0) { + const blockRegex = /\s*([\s\S]*?)\s*<\/options>/gi; + if (!blockRegex.test(text)) { return text; } - const numberedList = options.map((option, index) => ` ${index + 1}. ${option}`).join('\n'); - const optionsBlock = `Options:\n${numberedList}`; - return textWithoutOptions.length > 0 ? `${textWithoutOptions}\n\n${optionsBlock}` : optionsBlock; + blockRegex.lastIndex = 0; + // Replace EVERY complete options block in place (a message may contain more than + // one), keeping surrounding prose in its original position. Incomplete blocks + // (opening tag without closing tag, e.g. mid-stream) pass through untouched. + const formatted = text.replace(blockRegex, (_match, inner: string) => { + const optionRegex = /