Skip to content

Commit b6be363

Browse files
committed
对话界面调整布局v1
1 parent de247c2 commit b6be363

3 files changed

Lines changed: 91 additions & 13 deletions

File tree

apps/tui/src/ui/App.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -760,11 +760,12 @@ export const App: React.FC<AppProps> = ({
760760
// Handle agent query execution
761761
const handleAgentQuery = async (input: string) => {
762762
setCommandNotice(null);
763-
scrollAnchor.current.jumpToLatest();
764-
setChatScrollbackRows(0);
765-
// Add user message to chat history
766763
store.addUserMessage(input);
767764
store.clearInputBuffer();
765+
// Reset after the message enters history so the startup banner no longer
766+
// contributes to the viewport slice.
767+
scrollAnchor.current.jumpToLatest();
768+
setChatScrollbackRows(0);
768769

769770
// Prepare stable run input material. Retry attempts should not append
770771
// duplicate chat messages or send retry UI text back as model history.

apps/tui/src/ui/ChatArea.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildChatLines,
66
chatContentWidth,
77
countChatLines,
8+
type VisualLine,
89
type StartupInfo,
910
} from './transcript-lines.js';
1011

@@ -23,6 +24,42 @@ interface ChatAreaProps {
2324
startup?: StartupInfo | undefined;
2425
}
2526

27+
function messageIDFromKey(key: string): string | undefined {
28+
if (!key.startsWith('m:')) return undefined;
29+
const end = key.indexOf(':', 2);
30+
if (end === -1) return undefined;
31+
return key.slice(2, end);
32+
}
33+
34+
function snapTopToNearbyMessageStart(
35+
lines: VisualLine[],
36+
top: number,
37+
viewport: number,
38+
): number {
39+
if (top <= 0 || top >= lines.length) return top;
40+
41+
const currentID = messageIDFromKey(lines[top]?.key ?? '');
42+
if (!currentID) return top;
43+
44+
const currentKey = lines[top]?.key;
45+
if (currentKey === `m:${currentID}:h` || currentKey === `m:${currentID}:after`) {
46+
return top;
47+
}
48+
49+
let start = top;
50+
while (start > 0 && messageIDFromKey(lines[start - 1]?.key ?? '') === currentID) {
51+
start -= 1;
52+
}
53+
54+
if (lines[start]?.key !== `m:${currentID}:h`) return top;
55+
56+
// Preserve bottom anchoring for large blocks; fix the common case where the
57+
// slice starts one or two rows below a message header.
58+
const shift = top - start;
59+
if (shift <= Math.min(2, viewport - 1)) return start;
60+
return top;
61+
}
62+
2663
/**
2764
* Chat transcript viewport.
2865
*
@@ -79,15 +116,27 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
79116
} else if (total <= viewport) {
80117
visible = lines;
81118
} else {
82-
const top = Math.max(0, total - viewport - safeScroll);
83-
visible = lines.slice(top, top + viewport);
119+
// Early sessions should not jump straight to the bottom when the startup
120+
// banner is replaced by the first messages. Later sessions stay anchored to
121+
// the latest row, unless that would hide a nearby message header.
122+
const shouldPinEarlyConversation = messageCount <= 3 && safeScroll === 0;
123+
124+
if (shouldPinEarlyConversation) {
125+
visible = lines.slice(0, viewport);
126+
} else {
127+
const rawTop = Math.max(0, total - viewport - safeScroll);
128+
const top = safeScroll === 0
129+
? snapTopToNearbyMessageStart(lines, rawTop, viewport)
130+
: rawTop;
131+
visible = lines.slice(top, top + viewport);
132+
}
84133
}
85134

86135
const bottomPadding = Math.max(0, viewport - visible.length);
87136

88137
return (
89138
<Box flexDirection="column" flexGrow={1} overflowY="hidden">
90-
<Box height={viewport} flexDirection="column" overflowY="hidden">
139+
<Box flexDirection="column" overflowY="hidden">
91140
{visible.map((line) => line.node)}
92141
{Array.from({ length: bottomPadding }, (_, index) => (
93142
<Text key={`pad-bottom:${index}`}> </Text>

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export interface BuildChatLinesInput {
5757

5858
const INDENT = ' ';
5959
const INDENT_WIDTH = 2;
60+
const USER_MESSAGE_BORDER = '┃ ';
61+
const USER_MESSAGE_BORDER_WIDTH = textWidth(USER_MESSAGE_BORDER);
6062
const MAX_ELEMENT_LINES = 1000;
6163
const MAX_TABLE_ROWS = 12;
6264
const MIN_TABLE_CELL_WIDTH = 3;
@@ -108,6 +110,13 @@ export function buildChatLines(input: BuildChatLinesInput): VisualLine[] {
108110
return lines;
109111
}
110112

113+
// Keep the first message clear of the viewport edge when it replaces the
114+
// startup banner.
115+
if (hasMessages) {
116+
push('spacer:top:0', blankNode('spacer:top:0'));
117+
push('spacer:top:1', blankNode('spacer:top:1'));
118+
}
119+
111120
for (const message of input.messages) {
112121
pushMessageLines(message, toolCalls, bodyWidth, push);
113122
push(`m:${message.id}:after`, blankNode(`m:${message.id}:after`));
@@ -171,12 +180,31 @@ function pushMessageLines(
171180
bodyWidth: number,
172181
push: (key: string, node: React.ReactNode) => void,
173182
): void {
183+
const isUser = message.role === 'user';
174184
const headerKey = `m:${message.id}:h`;
175-
push(headerKey, <MessageHeader key={headerKey} message={message} />);
185+
const messageBodyWidth = isUser
186+
? Math.max(1, bodyWidth - USER_MESSAGE_BORDER_WIDTH)
187+
: bodyWidth;
188+
189+
const pushLine = (key: string, node: React.ReactNode) => {
190+
if (isUser) {
191+
push(
192+
key,
193+
<Box key={`box-${key}`}>
194+
<Text color="blue">{USER_MESSAGE_BORDER}</Text>
195+
{node}
196+
</Box>,
197+
);
198+
} else {
199+
push(key, node);
200+
}
201+
};
202+
203+
pushLine(headerKey, <MessageHeader key={headerKey} message={message} />);
176204

177205
if (message.elements.length === 0 && message.isStreaming) {
178206
const key = `m:${message.id}:thinking`;
179-
push(key, <ThinkingLine key={key} />);
207+
pushLine(key, <ThinkingLine key={key} />);
180208
return;
181209
}
182210

@@ -188,7 +216,7 @@ function pushMessageLines(
188216
// uniform marginTop={1} spacing within the flat single-row line model.
189217
const separate = (key: string): void => {
190218
if (blocksEmitted > 0) {
191-
push(key, blankNode(key));
219+
pushLine(key, blankNode(key));
192220
}
193221
};
194222

@@ -202,12 +230,12 @@ function pushMessageLines(
202230
}
203231
separate(`${keyBase}:gap`);
204232
blocksEmitted += 1;
205-
pushMarkdownLines(normalized, bodyWidth, keyBase, (key, node) => {
233+
pushMarkdownLines(normalized, messageBodyWidth, keyBase, (key, node) => {
206234
if (emittedLines >= MAX_ELEMENT_LINES) {
207235
return;
208236
}
209237
emittedLines += 1;
210-
push(key, node);
238+
pushLine(key, node);
211239
});
212240
return;
213241
}
@@ -220,7 +248,7 @@ function pushMessageLines(
220248
const key = `${keyBase}:tool`;
221249
separate(`${key}:gap`);
222250
blocksEmitted += 1;
223-
push(
251+
pushLine(
224252
key,
225253
<Box key={key} paddingLeft={INDENT_WIDTH}>
226254
<InlineToolCall toolCall={toolCall} showName />
@@ -230,7 +258,7 @@ function pushMessageLines(
230258

231259
if (message.isStreaming && message.elements.length > 0) {
232260
const key = `m:${message.id}:cursor`;
233-
push(key, <Text key={key} dimColor>{`${INDENT}▊`}</Text>);
261+
pushLine(key, <Text key={key} dimColor>{`${INDENT}▊`}</Text>);
234262
}
235263
}
236264

0 commit comments

Comments
 (0)