diff --git a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts index e3a53576..77c23f9d 100644 --- a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest'; +import React from 'react'; +import { renderToString } from 'ink'; +import { stripVTControlCharacters } from 'node:util'; import { adjustPreviewScrollOffsetForAppendedRows, buildPreviewViewport, getPreviewPanelTone, getPreviewChannelStatusText, + PreviewPane, } from '../../../tui/console/PreviewPane.js'; -import type { ConversationMessage } from '@ai-devkit/agent-manager'; +import { AgentStatus, type AgentInfo, type ConversationMessage } from '@ai-devkit/agent-manager'; const messages: ConversationMessage[] = [ { role: 'user', content: 'first question', timestamp: '2026-07-02T10:00:00Z' }, @@ -35,29 +39,29 @@ describe('PreviewPane helpers', () => { expect(viewport.hasBelow).toBe(false); expect(viewport.rows).toHaveLength(3); expect(viewport.rows).toEqual([ - { text: '↑ older', role: null }, - { text: 'user: second question', role: 'user' }, - { text: 'assistant: second answer', role: 'assistant' }, + { kind: 'indicator', text: '↑ older', role: null }, + { kind: 'header', text: '', role: 'assistant', timestamp: '2026-07-02T10:00:03Z' }, + { kind: 'content', text: 'second answer', role: 'assistant' }, ]); }); it('builds a viewport over older conversation content at a positive offset', () => { - const viewport = buildPreviewViewport(messages, 3, 3); + const viewport = buildPreviewViewport(messages, 3, 10); - expect(viewport.clampedOffset).toBe(3); + expect(viewport.clampedOffset).toBe(10); expect(viewport.hasAbove).toBe(false); expect(viewport.hasBelow).toBe(true); expect(viewport.rows).toHaveLength(3); expect(viewport.rows).toEqual([ - { text: ' ↓ newer', role: null }, - { text: 'user: first question', role: 'user' }, - { text: 'assistant: first answer', role: 'assistant' }, + { kind: 'indicator', text: ' ↓ newer', role: null }, + { kind: 'header', text: '', role: 'user', timestamp: '2026-07-02T10:00:00Z' }, + { kind: 'content', text: 'first question', role: 'user' }, ]); }); it('clamps requested offsets to the valid scroll range', () => { expect(buildPreviewViewport(messages, 3, -2).clampedOffset).toBe(0); - expect(buildPreviewViewport(messages, 3, 99).clampedOffset).toBe(3); + expect(buildPreviewViewport(messages, 3, 99).clampedOffset).toBe(10); }); it('keeps the rendered body inside the viewport budget when overflow affordances are shown', () => { @@ -66,7 +70,48 @@ describe('PreviewPane helpers', () => { expect(viewport.hasAbove).toBe(true); expect(viewport.hasBelow).toBe(true); expect(viewport.rows).toHaveLength(4); - expect(viewport.rows[0]).toEqual({ text: '↑ older ↓ newer', role: null }); + expect(viewport.rows[0]).toEqual({ + kind: 'indicator', + text: '↑ older · user continued ↓ newer', + role: null, + }); + }); + + it('builds a structured block for multiline message content', () => { + const viewport = buildPreviewViewport([ + { role: 'assistant', content: 'Summary\n\n- first item', timestamp: '2026-07-02T10:00:00Z' }, + ], 6, 0); + + expect(viewport.rows).toEqual([ + { kind: 'header', text: '', role: 'assistant', timestamp: '2026-07-02T10:00:00Z' }, + { kind: 'content', text: 'Summary', role: 'assistant' }, + { kind: 'content', text: '', role: 'assistant' }, + { kind: 'content', text: '- first item', role: 'assistant' }, + ]); + }); + + it('renders conversation turns using the agent detail format', () => { + const agent = { + name: 'preview-test', + type: 'codex', + status: AgentStatus.RUNNING, + projectPath: '/tmp/project', + lastActive: new Date(), + } as AgentInfo; + const output = stripVTControlCharacters(renderToString(React.createElement(PreviewPane, { + agent, + messages: [ + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'first answer\nwith detail' }, + ], + error: null, + isLoading: false, + maxLines: 8, + }), { columns: 80 })); + + expect(output).toContain('user:\n first question\n\nassistant:\n first answer\n with detail'); + expect(output).not.toContain('assistant: first answer'); + expect(output).not.toContain('assistant │ first answer'); }); it('adjusts positive scroll offsets by newly appended rendered rows', () => { diff --git a/packages/cli/src/__tests__/tui/console/computeLayout.test.ts b/packages/cli/src/__tests__/tui/console/computeLayout.test.ts index 38bbad3f..8ed478d4 100644 --- a/packages/cli/src/__tests__/tui/console/computeLayout.test.ts +++ b/packages/cli/src/__tests__/tui/console/computeLayout.test.ts @@ -1,13 +1,27 @@ import { describe, it, expect } from 'vitest'; -// computeLayout is a pure function exported from ConsoleApp — import only the function, -// not the React component tree, to avoid JSX in the test environment. -import { computeCenteredDialog, computeLayout } from '../../../tui/console/ConsoleApp.js'; +// Import pure console configuration helpers without rendering the component tree. +import { + AGENT_CONSOLE_RENDER_OPTIONS, + computeCenteredDialog, + computeLayout, +} from '../../../tui/console/ConsoleApp.js'; // Constants mirrored from ConsoleApp.tsx for assertions const LIST_PANE_WIDTH = 48; const MIN_CONTENT_HEIGHT = 12; const INPUT_BOX_CHROME_ROWS = 2; +describe('agent console render options', () => { + it('uses incremental 60 FPS rendering for responsive scrolling', () => { + expect(AGENT_CONSOLE_RENDER_OPTIONS).toEqual({ + alternateScreen: true, + exitOnCtrlC: true, + incrementalRendering: true, + maxFps: 60, + }); + }); +}); + describe('computeLayout', () => { describe('wide mode (narrow=false)', () => { it('uses fixed LIST_PANE_WIDTH', () => { diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index c7cd9b40..9805fefd 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -54,7 +54,7 @@ import { createDefaultAgentGroupService, } from '../services/agent/agent-group.service.js'; import { registerAgentGroupCommand } from './agent/group.command.js'; -import { ConsoleApp } from '../tui/console/ConsoleApp.js'; +import { AGENT_CONSOLE_RENDER_OPTIONS, ConsoleApp } from '../tui/console/ConsoleApp.js'; import { generateAgentName } from '../util/agent.js'; import { select } from '@inquirer/prompts'; @@ -842,7 +842,7 @@ export function registerAgentCommand(program: Command): void { const manager = createAgentManager(); const { waitUntilExit } = render( createElement(ConsoleApp, { manager }), - { alternateScreen: true, exitOnCtrlC: true }, + AGENT_CONSOLE_RENDER_OPTIONS, ); await waitUntilExit(); })); diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 02c694e0..46c7d1db 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Box, useApp, useInput } from 'ink'; +import { Box, useApp, useInput, type RenderOptions } from 'ink'; import type { AgentManager } from '@ai-devkit/agent-manager'; import { ConsoleProvider, useConsoleContext } from './state/ConsoleContext.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; @@ -36,6 +36,13 @@ const HEADER_HEIGHT = 1; const MIN_CONTENT_HEIGHT = 12; const INPUT_BOX_CHROME_ROWS = 2; +export const AGENT_CONSOLE_RENDER_OPTIONS: RenderOptions = { + alternateScreen: true, + exitOnCtrlC: true, + incrementalRendering: true, + maxFps: 60, +}; + export function computeCenteredDialog(cols: number, rows: number) { const width = Math.min(56, Math.max(24, cols - 6)); return { diff --git a/packages/cli/src/tui/console/PreviewPane.tsx b/packages/cli/src/tui/console/PreviewPane.tsx index 20157afd..d364b05e 100644 --- a/packages/cli/src/tui/console/PreviewPane.tsx +++ b/packages/cli/src/tui/console/PreviewPane.tsx @@ -25,7 +25,6 @@ const ROLE_COLOR: Record total + Math.max(1, msg.content.split('\n').length), 0); + return messages.reduce( + (total, msg, index) => total + Math.max(1, msg.content.split('\n').length) + 1 + (index > 0 ? 1 : 0), + 0, + ); } export function adjustPreviewScrollOffsetForAppendedRows( @@ -64,12 +68,12 @@ export function buildPreviewViewport( requestedOffset: number, ): PreviewViewport { const budget = Math.max(1, Math.floor(maxLines)); - const rows = messages.flatMap((msg) => { + const rows = messages.flatMap((msg, index) => { const contentLines = msg.content.split('\n'); - const first = contentLines[0] ?? ''; return [ - { text: `${msg.role}: ${first}`, role: msg.role }, - ...contentLines.slice(1).map(line => ({ text: ` ${line}`, role: null })), + ...(index > 0 ? [{ kind: 'separator' as const, text: '', role: null }] : []), + { kind: 'header', text: '', role: msg.role, timestamp: msg.timestamp }, + ...contentLines.map(line => ({ kind: 'content', text: line, role: msg.role })), ]; }); const contentBudget = rows.length > budget ? Math.max(1, budget - 1) : budget; @@ -79,8 +83,16 @@ export function buildPreviewViewport( const start = Math.max(0, end - contentBudget); const hasAbove = start > 0; const hasBelow = end < rows.length; - const indicator = hasAbove || hasBelow - ? [{ text: `${hasAbove ? '↑ older' : ' '}${hasBelow ? ' ↓ newer' : ''}`, role: null }] + const firstVisible = rows[start]; + const continuation = hasAbove && firstVisible?.kind === 'content' && firstVisible.role + ? ` · ${firstVisible.role} continued` + : ''; + const indicator: PreviewViewportRow[] = hasAbove || hasBelow + ? [{ + kind: 'indicator', + text: `${hasAbove ? '↑ older' : ' '}${continuation}${hasBelow ? ' ↓ newer' : ''}`, + role: null, + }] : []; return { rows: [...indicator, ...rows.slice(start, end)], @@ -176,9 +188,27 @@ const PreviewPaneInner: React.FC = ({ body = ( <> {viewport?.rows.map((row, idx) => ( - - {row.text} - + row.kind === 'indicator' ? ( + + {row.text} + + ) : row.kind === 'header' && row.role ? ( + + {row.timestamp ? [{new Date(row.timestamp).toLocaleTimeString()}] : null} + {row.role}: + + ) : row.kind === 'separator' ? ( + + + + ) : ( + + + + {row.text || ' '} + + + ) ))} );