Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 56 additions & 11 deletions packages/cli/src/__tests__/tui/console/PreviewPane.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/__tests__/tui/console/computeLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
}));
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/tui/console/ConsoleApp.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 41 additions & 11 deletions packages/cli/src/tui/console/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,17 @@ const ROLE_COLOR: Record<ConversationMessage['role'], 'green' | 'cyan' | 'yellow
system: TUI_COLORS.warning,
};


function shortPath(p: string): string {
const home = process.env.HOME ?? '';
if (home && p.startsWith(home)) return '~' + p.slice(home.length);
return p;
}

export interface PreviewViewportRow {
kind: 'header' | 'content' | 'separator' | 'indicator';
text: string;
role: ConversationMessage['role'] | null;
timestamp?: string;
}

export interface PreviewViewport {
Expand All @@ -46,7 +47,10 @@ export interface PreviewViewport {
}

export function countPreviewRows(messages: ConversationMessage[]): number {
return messages.reduce((total, msg) => 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(
Expand All @@ -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<PreviewViewportRow>((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<PreviewViewportRow>(line => ({ kind: 'content', text: line, role: msg.role })),
];
});
const contentBudget = rows.length > budget ? Math.max(1, budget - 1) : budget;
Expand All @@ -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)],
Expand Down Expand Up @@ -176,9 +188,27 @@ const PreviewPaneInner: React.FC<PreviewPaneProps> = ({
body = (
<>
{viewport?.rows.map((row, idx) => (
<Box key={idx}>
<Text color={row.role ? ROLE_COLOR[row.role] : undefined}>{row.text}</Text>
</Box>
row.kind === 'indicator' ? (
<Box key={idx}>
<Text dimColor>{row.text}</Text>
</Box>
) : row.kind === 'header' && row.role ? (
<Box key={idx}>
{row.timestamp ? <Text dimColor>[{new Date(row.timestamp).toLocaleTimeString()}] </Text> : null}
<Text color={ROLE_COLOR[row.role]} bold>{row.role}:</Text>
</Box>
) : row.kind === 'separator' ? (
<Box key={idx}>
<Text> </Text>
</Box>
) : (
<Box key={idx}>
<Text> </Text>
<Box flexGrow={1}>
<Text>{row.text || ' '}</Text>
</Box>
</Box>
)
))}
</>
);
Expand Down
Loading