diff --git a/ui-tui/src/__tests__/messageLine.test.ts b/ui-tui/src/__tests__/messageLine.test.ts new file mode 100644 index 00000000000..b330bbd2374 --- /dev/null +++ b/ui-tui/src/__tests__/messageLine.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { shouldShowResponseSeparator } from '../components/messageLine.js' + +describe('shouldShowResponseSeparator', () => { + it('separates assistant response text from visible details', () => { + expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final', thinking: 'plan' }, true)).toBe(true) + }) + + it('does not add a response separator without details or body text', () => { + expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final' }, false)).toBe(false) + expect(shouldShowResponseSeparator({ role: 'assistant', text: ' ', thinking: 'plan' }, true)).toBe(false) + }) + + it('does not add response separators to non-assistant transcript rows', () => { + expect(shouldShowResponseSeparator({ role: 'user', text: 'prompt' }, true)).toBe(false) + expect(shouldShowResponseSeparator({ role: 'system', text: 'note' }, true)).toBe(false) + }) +}) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index b93df65d72a..37cb9c009ce 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -32,6 +32,45 @@ describe('virtual height estimates', () => { ) }) + it('accounts for the response separator when assistant details are visible', () => { + const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' } + + expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe( + estimatedMsgHeight(msg, 80, { compact: false, details: false }) + 3 + ) + }) + + it('does not account for a response separator without visible details', () => { + const msg: Msg = { role: 'assistant', text: 'ok' } + + expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe( + estimatedMsgHeight(msg, 80, { compact: false, details: false }) + ) + }) + + it('honors per-section visibility when estimating response separators', () => { + const thinkingOnly: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' } + const toolsOnly: Msg = { role: 'assistant', text: 'ok', tools: ['Tool A'] } + + expect( + estimatedMsgHeight(thinkingOnly, 80, { + compact: false, + details: true, + thinkingVisible: false, + toolsVisible: true + }) + ).toBe(estimatedMsgHeight(thinkingOnly, 80, { compact: false, details: false })) + + expect( + estimatedMsgHeight(toolsOnly, 80, { + compact: false, + details: true, + thinkingVisible: true, + toolsVisible: false + }) + ).toBe(estimatedMsgHeight(toolsOnly, 80, { compact: false, details: false })) + }) + it('reserves two extra rows for the inter-turn separator on non-first user messages', () => { const msg: Msg = { role: 'user', text: 'follow-up question' } const base = estimatedMsgHeight(msg, 80, { compact: false, details: false }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 4d7ab8926ba..ad2348d0dce 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -252,7 +252,10 @@ export function useMainApp(gw: GatewayClient) { return `${thinking}:${tools}` }, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]) - const detailsVisible = detailsLayoutKey !== 'hidden:hidden' + const [thinkingDetailsMode, toolsDetailsMode] = detailsLayoutKey.split(':') + const thinkingDetailsVisible = thinkingDetailsMode !== 'hidden' + const toolsDetailsVisible = toolsDetailsMode !== 'hidden' + const detailsVisible = thinkingDetailsVisible || toolsDetailsVisible const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt) const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}` @@ -281,10 +284,21 @@ export function useMainApp(gw: GatewayClient) { estimatedMsgHeight(virtualRows[index]!.msg, cols, { compact: ui.compact, details: detailsVisible, + thinkingVisible: thinkingDetailsVisible, + toolsVisible: toolsDetailsVisible, userPrompt: ui.theme.brand.prompt, withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx }), - [cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows] + [ + cols, + detailsVisible, + firstUserIdx, + thinkingDetailsVisible, + toolsDetailsVisible, + ui.compact, + ui.theme.brand.prompt, + virtualRows + ] ) const syncHeightCache = useCallback( diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 4d1481373ab..2a7f0bbba23 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -109,6 +109,8 @@ export const MessageLine = memo(function MessageLine({ const showDetails = (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking)) + const showResponseSeparator = shouldShowResponseSeparator(msg, showDetails) + const content = (() => { if (msg.kind === 'slash') { return {msg.text} @@ -195,6 +197,17 @@ export const MessageLine = memo(function MessageLine({ )} + {showResponseSeparator && ( + + + └─ + + + Response + + + )} + @@ -208,6 +221,9 @@ export const MessageLine = memo(function MessageLine({ ) }) +export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean => + msg.role === 'assistant' && showDetails && /\S/.test(msg.text) + interface MessageLineProps { cols: number compact?: boolean diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 874f8a1b8dc..4ae2ee3f734 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -1,6 +1,6 @@ +import { TERMUX_TUI_MODE } from '../config/env.js' import type { Msg } from '../types.js' -import { TERMUX_TUI_MODE } from '../config/env.js' import { transcriptBodyWidth } from './inputMetrics.js' const hashText = (text: string) => { @@ -72,11 +72,15 @@ export const estimatedMsgHeight = ( { compact, details, + thinkingVisible = details, + toolsVisible = details, userPrompt = '', withSeparator = false }: { compact: boolean details: boolean + thinkingVisible?: boolean + toolsVisible?: boolean userPrompt?: string withSeparator?: boolean } @@ -111,7 +115,17 @@ export const estimatedMsgHeight = ( } if (details) { - h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth) + const hasVisibleTools = toolsVisible && Boolean(msg.tools?.length) + const hasVisibleThinking = thinkingVisible && /\S/.test(msg.thinking ?? '') + const hasVisibleDetails = hasVisibleTools || hasVisibleThinking + + if (hasVisibleDetails) { + h += (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) + (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0) + + if (msg.role === 'assistant' && /\S/.test(msg.text)) { + h += 2 + } + } } if (msg.role === 'user' || msg.kind === 'diff') {