Skip to content

Commit e7f3cb8

Browse files
authored
Merge pull request #15 from SymbolZH/main
启动的banner
2 parents f5dc56e + 3754e1e commit e7f3cb8

3 files changed

Lines changed: 118 additions & 23 deletions

File tree

apps/tui/src/ui/ChatArea.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,24 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
6767
const safeScroll = Math.max(0, Math.min(scrollbackRows, maxScroll));
6868
const top = Math.max(0, total - viewport - safeScroll);
6969
const visible = lines.slice(top, top + viewport);
70-
// Keep the newest content pinned to the bottom of the viewport when the
71-
// transcript is shorter than the available rows.
70+
// The startup banner should sit at the top on a fresh session. Once chat
71+
// content exists, keep the newest content pinned near the input as before.
72+
const messageCount = totalMessageCount ?? messages.length;
73+
const topAlignStartup = startup !== undefined && messages.length === 0 && messageCount === 0;
7274
const padCount = Math.max(0, viewport - visible.length);
75+
const topPadding = topAlignStartup ? 0 : padCount;
76+
const bottomPadding = topAlignStartup ? padCount : 0;
7377

7478
return (
7579
<Box flexDirection="column" flexGrow={1} overflowY="hidden">
7680
<Box height={viewport} flexDirection="column" overflowY="hidden">
77-
{Array.from({ length: padCount }, (_, index) => (
81+
{Array.from({ length: topPadding }, (_, index) => (
7882
<Text key={`pad:${index}`}> </Text>
7983
))}
8084
{visible.map((line) => line.node)}
85+
{Array.from({ length: bottomPadding }, (_, index) => (
86+
<Text key={`pad-bottom:${index}`}> </Text>
87+
))}
8188
</Box>
8289
</Box>
8390
);

apps/tui/src/ui/transcript-lines.tsx

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ const INDENT_WIDTH = 2;
6060
const MAX_ELEMENT_LINES = 1000;
6161
const MAX_TABLE_ROWS = 12;
6262
const MIN_TABLE_CELL_WIDTH = 3;
63+
const STARTUP_BANNER_ART = [
64+
' ____ _ _ _ ',
65+
'| _ \\ __ _| |_ __ _| | __ _ ___ _ __ | |_ ',
66+
'| | | |/ _` | __/ _` | | / _` |/ _ \\ `_ \\| __|',
67+
'| |_| | (_| | || (_| | |__| (_| | __/ | | | |_ ',
68+
'|____/ \\__,_|\\__\\__,_|_____\\__, |\\___|_| |_|\\__|',
69+
' |___/ ',
70+
];
71+
const STARTUP_BANNER_ART_WIDTH = Math.max(
72+
...STARTUP_BANNER_ART.map((line) => textWidth(line)),
73+
);
74+
const STARTUP_BANNER_MAX_WIDTH = 72;
6375

6476
/** Total display-column budget for a single chat row (mirrors the old estimate). */
6577
export function chatContentWidth(columns: number): number {
@@ -661,40 +673,71 @@ function pushStartupLines(
661673
): void {
662674
const conn = connectionDisplay(startup.connectionStatus);
663675
const run = runDisplay(startup.runStatus);
664-
const session = startup.threadId ? ` session: ${startup.threadId.slice(0, 8)}` : '';
676+
const bannerWidth = Math.max(20, Math.min(contentWidth, STARTUP_BANNER_MAX_WIDTH));
677+
const border = `+${'-'.repeat(Math.max(0, bannerWidth - 2))}+`;
678+
const session = startup.threadId ? startup.threadId.slice(0, 8) : 'new';
679+
const statusText = `${conn.icon} ${conn.text} | ${run.icon} ${run.text}`;
680+
const showArt = bannerWidth >= STARTUP_BANNER_ART_WIDTH + 4;
681+
682+
push('startup:border:top', <Text key="startup:border:top" color="cyan">{border}</Text>);
683+
push('startup:title', (
684+
<Text key="startup:title" bold color="cyan">
685+
{bannerContent('DataAgent', bannerWidth)}
686+
</Text>
687+
));
688+
689+
if (showArt) {
690+
STARTUP_BANNER_ART.forEach((line, index) => {
691+
const key = `startup:art:${index}`;
692+
push(
693+
key,
694+
<Text key={key} color="cyan">
695+
{bannerContent(padToWidth(line.trimEnd(), STARTUP_BANNER_ART_WIDTH), bannerWidth)}
696+
</Text>,
697+
);
698+
});
699+
}
665700

666701
push(
667-
'startup:title',
668-
<Text key="startup:title">
669-
<Text bold color="cyan">DataAgent TUI</Text>
670-
{session ? <Text dimColor>{truncateToWidth(session, Math.max(0, contentWidth - 13))}</Text> : null}
671-
</Text>,
672-
);
673-
push(
674-
'startup:model',
675-
<Text key="startup:model">
676-
<Text dimColor>model: </Text>
677-
<Text>{truncateToWidth(startup.modelName, Math.max(0, contentWidth - 11))}</Text>
702+
'startup:session',
703+
<Text key="startup:session" dimColor>
704+
{bannerContent(`session ${session} | model ${startup.modelName}`, bannerWidth)}
678705
</Text>,
679706
);
680707
push(
681708
'startup:dir',
682-
<Text key="startup:dir">
683-
<Text dimColor>directory: </Text>
684-
<Text>{truncateToWidth(startup.directory, Math.max(0, contentWidth - 11))}</Text>
709+
<Text key="startup:dir" dimColor>
710+
{bannerContent(`cwd ${startup.directory}`, bannerWidth)}
685711
</Text>,
686712
);
687713
push(
688714
'startup:status',
689715
<Text key="startup:status">
690-
<Text color={conn.color}>{conn.icon} {conn.text}</Text>
691-
<Text dimColor> | </Text>
692-
<Text color={run.color}>{run.icon} {run.text}</Text>
716+
{bannerContent(statusText, bannerWidth)}
693717
</Text>,
694718
);
719+
push('startup:border:bottom', <Text key="startup:border:bottom" color="cyan">{border}</Text>);
695720
push('startup:after', blankNode('startup:after'));
696721
}
697722

723+
function bannerContent(text: string, width: number): string {
724+
const innerWidth = Math.max(0, width - 4);
725+
return `| ${centerToWidth(text, innerWidth)} |`;
726+
}
727+
728+
function centerToWidth(text: string, width: number): string {
729+
const fitted = truncateToWidth(text, width);
730+
const remaining = Math.max(0, width - textWidth(fitted));
731+
const left = Math.floor(remaining / 2);
732+
const right = remaining - left;
733+
return `${' '.repeat(left)}${fitted}${' '.repeat(right)}`;
734+
}
735+
736+
function padToWidth(text: string, width: number): string {
737+
const fitted = truncateToWidth(text, width);
738+
return `${fitted}${' '.repeat(Math.max(0, width - textWidth(fitted)))}`;
739+
}
740+
698741
function blankNode(key: string): React.ReactNode {
699742
return <Text key={key}> </Text>;
700743
}

apps/tui/test-chat-viewport.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,32 @@ function eq<T>(actual: T, expected: T): boolean {
2727
}
2828

2929
function visualLineText(line: { node: unknown }): string {
30-
const props = (line.node as { props?: { segments?: Array<{ text?: unknown }> } }).props;
31-
return (props?.segments ?? []).map((segment) => String(segment.text ?? '')).join('');
30+
return reactNodeText(line.node);
31+
}
32+
33+
function reactNodeText(node: unknown): string {
34+
if (node === null || node === undefined || typeof node === 'boolean') {
35+
return '';
36+
}
37+
if (typeof node === 'string' || typeof node === 'number') {
38+
return String(node);
39+
}
40+
if (Array.isArray(node)) {
41+
return node.map(reactNodeText).join('');
42+
}
43+
if (typeof node === 'object') {
44+
const props = (node as {
45+
props?: {
46+
children?: unknown;
47+
segments?: Array<{ text?: unknown }>;
48+
};
49+
}).props;
50+
if (props?.segments) {
51+
return props.segments.map((segment) => String(segment.text ?? '')).join('');
52+
}
53+
return reactNodeText(props?.children);
54+
}
55+
return '';
3256
}
3357

3458
function textMessage(id: string, content: string): DisplayMessage {
@@ -82,6 +106,27 @@ check(chatContentWidth(120) === 116, 'content width is columns - 4');
82106
check(chatViewportRows(40, { commandNotice: false, activeTab: 'chat' }) === 36, 'chat viewport for 40 rows is 36');
83107
check(chatViewportRows(40, { commandNotice: true, activeTab: 'chat' }) === 35, 'chat viewport with notice is 35');
84108

109+
// --- startup banner ---
110+
const startupLines = buildChatLines({
111+
messages: [],
112+
artifacts: [],
113+
columns: 80,
114+
startup: {
115+
threadId: '12345678-abcd',
116+
connectionStatus: 'connected',
117+
runStatus: 'idle',
118+
modelName: 'test-model',
119+
directory: '/tmp/dataagent',
120+
},
121+
});
122+
const startupTexts = startupLines.map(visualLineText);
123+
check(startupLines[0]?.key === 'startup:border:top', 'startup transcript begins with the banner border');
124+
check(startupTexts.some((line) => line.includes('DataAgent')), 'startup banner includes the DataAgent wordmark');
125+
check(
126+
startupTexts.every((line) => textWidth(line) <= chatContentWidth(80)),
127+
'startup banner rows fit within chat content width',
128+
);
129+
85130
// --- exact line counts ---
86131
const columns = 120;
87132
check(

0 commit comments

Comments
 (0)