Skip to content

Commit 4993dfe

Browse files
committed
feat(kimi-code): pin scrolled-past user messages above the fullscreen transcript
In fullscreen mode, once a user message's first content line scrolls out of view, a one-line summary pins above the transcript until the message scrolls back into view. The summary merges every fully scrolled-out logical line of a multi-line message, growing line by line without overlapping what is still visible, and clicking it jumps back to the original message. While following the output tail, the pin shows only for the latest user message and is suppressed during the window before the follow scroll passes it. The user message bullet is now ❯ instead of ✨ in all themes: ✨ had too little contrast to read comfortably on light backgrounds.
1 parent be7d5f5 commit 4993dfe

17 files changed

Lines changed: 1183 additions & 16 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/pi-tui": patch
3+
---
4+
5+
Add an opt-in `renderOnLayoutScroll` ScrollView option: when enabled, a layout pass that moves the scroll position or flips follow-end requests a follow-up render.

‎.changeset/sticky-user-messages.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
Add sticky user messages in fullscreen mode: when a user message scrolls past the top, a summary line pins above the transcript, and clicking it jumps back to the original message.

‎.changeset/user-message-bullet.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
Use ❯ instead of ✨ as the user message bullet — ✨ was hard to see in light themes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {
2+
Text,
3+
truncateToWidth,
4+
visibleWidth,
5+
type Component,
6+
type TuiMouseEvent,
7+
type TuiMouseEventResult,
8+
} from '@moonshot-ai/pi-tui';
9+
10+
import { TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering';
11+
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
12+
import { currentTheme } from '#/tui/theme';
13+
import {
14+
judgeStickyUserMessage,
15+
type StickyJudgmentEntry,
16+
type StickyUserMessageJudgment,
17+
} from '#/tui/utils/sticky-user-message';
18+
19+
export interface StickyUserMessageSource {
20+
measure(width: number): StickyJudgmentEntry[];
21+
scrollState(): { scrollTop: number; following: boolean };
22+
scrollTo(y: number): void;
23+
}
24+
25+
export class StickyUserMessageComponent implements Component {
26+
private judgment: StickyUserMessageJudgment | null = null;
27+
28+
constructor(private readonly source: StickyUserMessageSource) {}
29+
30+
invalidate(): void {}
31+
32+
render(width: number): string[] {
33+
const safeWidth = Math.max(0, width);
34+
const { scrollTop, following } = this.source.scrollState();
35+
this.judgment = judgeStickyUserMessage({
36+
entries: this.source.measure(Math.max(1, safeWidth)),
37+
scrollTop,
38+
following,
39+
});
40+
if (this.judgment === null || safeWidth <= 0) return [];
41+
42+
const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET);
43+
const contentWidth = Math.max(1, safeWidth - visibleWidth(bullet));
44+
45+
const colored = currentTheme.boldFg('roleUser', this.judgment.summary);
46+
const wrapped = new Text(colored, 0, 0).render(contentWidth);
47+
let line = (wrapped[0] ?? '').trimEnd();
48+
if (wrapped.length > 1) {
49+
line = truncateToWidth(line + TRUNCATION_ELLIPSIS, contentWidth, TRUNCATION_ELLIPSIS);
50+
}
51+
return [truncateToWidth(bullet + line, safeWidth, TRUNCATION_ELLIPSIS)];
52+
}
53+
54+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
55+
if (event.type === 'click' && event.button === 'left' && this.judgment !== null) {
56+
this.source.scrollTo(this.judgment.targetY);
57+
return { handled: true };
58+
}
59+
return undefined;
60+
}
61+
}

‎apps/kimi-code/src/tui/components/messages/user-message.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Renders a user message in the transcript.
33
*/
44

5-
import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui';
5+
import { Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from '@moonshot-ai/pi-tui';
66

77
import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
88
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
@@ -11,6 +11,38 @@ import type { ImageAttachment } from '#/tui/utils/image-attachment-store';
1111
import { markOsc133Zone } from '#/tui/utils/osc133';
1212
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
1313

14+
function firstContentLineIndex(lines: string[]): number {
15+
let first = 0;
16+
while (first < lines.length && lines[first]!.trim() === '') first++;
17+
return first;
18+
}
19+
20+
/**
21+
* Visual row index of the message's first visible text line: one leading
22+
* Spacer row plus one row per leading blank logical line. The sticky pill
23+
* anchors its pin threshold and click target to this row. It must be derived
24+
* from the raw content rather than the rendered lines: the bullet prefix
25+
* makes even a blank first text row visibly non-empty, so scanning rendered
26+
* rows for the first non-blank one would miss leading blank lines.
27+
*/
28+
export function userMessageContentOffset(text: string): number {
29+
return 1 + firstContentLineIndex(text.split(/\r?\n/));
30+
}
31+
32+
/**
33+
* Visual row count of each logical content line after the leading blanks, at
34+
* the width the message would render with its bullet. The sticky pill uses
35+
* this to map "visual rows scrolled out" back to logical lines.
36+
*/
37+
export function userMessageLineHeights(text: string, width: number, bullet?: string): number[] {
38+
const marker = bullet ?? USER_MESSAGE_BULLET;
39+
const contentWidth = Math.max(1, width - visibleWidth(marker));
40+
const lines = text.split(/\r?\n/);
41+
return lines
42+
.slice(firstContentLineIndex(lines))
43+
.map((line) => wrapTextWithAnsi(line.replaceAll('\t', ' '), contentWidth).length);
44+
}
45+
1446
export class UserMessageComponent implements Component {
1547
private text: string;
1648
private readonly bullet?: string;

‎apps/kimi-code/src/tui/constant/symbols.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export const STATUS_BULLET = '● ';
33

44
// Shared transcript markers. Keep widths stable because message wrapping
55
// assumes the marker occupies the leading cells.
6-
export const USER_MESSAGE_BULLET = '✨ ';
6+
export const USER_MESSAGE_BULLET = '❯ ';
77
export const SUCCESS_MARK = '✓ ';
88
export const FAILURE_MARK = '✗ ';
99

‎apps/kimi-code/src/tui/tui-state.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ import { NotifyPanelComponent } from './components/chrome/notify-panel';
1717
import { TodoPanelComponent } from './components/chrome/todo-panel';
1818
import type { SessionRow } from './components/dialogs/session-picker';
1919
import { CustomEditor } from './components/editor/custom-editor';
20+
import { StickyUserMessageComponent } from './components/messages/sticky-user-message';
21+
import { userMessageContentOffset, userMessageLineHeights } from './components/messages/user-message';
2022
import { DEFAULT_MARKDOWN_CONFIG, DEFAULT_TUI_CONFIG } from './config';
2123
import { CHROME_GUTTER } from './constant/rendering';
2224
import type { TasksBrowserState } from './controllers/tasks-browser';
2325
import { currentTheme, type Theme } from './theme';
2426
import { setMarkdownAltScreenActive, setMarkdownMermaidMode, setMarkdownRenderLatex, setMarkdownRenderRequester } from './utils/markdown-options';
2527
import { createTerminalState, type TerminalState } from './utils/terminal-state';
28+
import { getTranscriptComponentEntry } from './utils/transcript-component-metadata';
2629
import {
2730
INITIAL_LIVE_PANE,
2831
type AppState,
@@ -159,7 +162,32 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
159162
primary: true,
160163
overscroll: 'chain',
161164
scrollbar: 'auto',
165+
renderOnLayoutScroll: true,
162166
});
167+
const stickyUserMessage = new StickyUserMessageComponent({
168+
measure: (width) =>
169+
transcriptContainer.children.map((child) => {
170+
const entry = getTranscriptComponentEntry(child);
171+
const lines = child.render(width);
172+
const isUserMessage = entry?.kind === 'user' && entry.bullet !== '';
173+
return {
174+
kind: entry?.kind,
175+
bullet: entry?.bullet,
176+
content: entry?.content ?? '',
177+
height: lines.length,
178+
contentOffset: isUserMessage ? userMessageContentOffset(entry.content) : undefined,
179+
lineHeights: isUserMessage
180+
? userMessageLineHeights(entry.content, width, entry.bullet)
181+
: [],
182+
};
183+
}),
184+
scrollState: () => ({ scrollTop: scrollView.scrollTop, following: scrollView.isFollowingEnd }),
185+
scrollTo: (y) => {
186+
scrollView.scrollTo(y);
187+
},
188+
});
189+
const stickyUserMessageContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
190+
stickyUserMessageContainer.addChild(stickyUserMessage);
163191
dockContainer = new VStack();
164192
dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 });
165193
dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 });
@@ -169,6 +197,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
169197
dockContainer.addChild(surveyContainer, { shrink: 0, minSize: 0 });
170198
dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 });
171199
const root = new VStack();
200+
root.addChild(stickyUserMessageContainer, { shrink: 0, minSize: 0 });
172201
root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 });
173202
root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 });
174203
ui.setLayoutRoot(root);

‎apps/kimi-code/src/tui/types.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ export interface TranscriptEntry {
234234
modelText?: boolean;
235235
color?: ColorToken;
236236
detail?: string;
237-
/** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */
237+
/** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` takes the bullet's place). */
238238
bullet?: string;
239239
toolCallData?: ToolCallBlockData;
240240
backgroundAgentStatus?: BackgroundAgentStatusData;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { TranscriptEntryKind } from '../types';
2+
3+
export interface StickyJudgmentEntry {
4+
kind: TranscriptEntryKind | undefined;
5+
bullet?: string;
6+
content: string;
7+
height: number;
8+
contentOffset?: number;
9+
lineHeights: number[];
10+
}
11+
12+
export interface StickyUserMessageJudgment {
13+
index: number;
14+
summary: string;
15+
targetY: number;
16+
}
17+
18+
export function summarizeStickyUserMessage(content: string, lineCount = 1): string {
19+
const lines = content.split(/\r?\n/);
20+
let first = 0;
21+
while (first < lines.length && lines[first]!.trim() === '') first++;
22+
return lines
23+
.slice(first, first + Math.max(1, lineCount))
24+
.join(' ')
25+
.replaceAll(/\s+/g, ' ')
26+
.trim();
27+
}
28+
29+
function countScrolledOutLines(lineHeights: number[], rowsOut: number): number {
30+
let rows = 0;
31+
let count = 0;
32+
for (const height of lineHeights) {
33+
const lineRows = Math.max(1, height);
34+
if (rows + lineRows > rowsOut) break;
35+
rows += lineRows;
36+
count++;
37+
}
38+
return Math.max(1, count);
39+
}
40+
41+
export function judgeStickyUserMessage(input: {
42+
entries: readonly StickyJudgmentEntry[];
43+
scrollTop: number;
44+
following: boolean;
45+
}): StickyUserMessageJudgment | null {
46+
const { entries, scrollTop, following } = input;
47+
if (scrollTop <= 0) return null;
48+
let y = 0;
49+
let latest: StickyUserMessageJudgment | null = null;
50+
let pinned: StickyUserMessageJudgment | null = null;
51+
for (let index = 0; index < entries.length; index++) {
52+
const entry = entries[index]!;
53+
const entryY = y;
54+
const height = Math.max(0, entry.height);
55+
y += height;
56+
if (entry.kind !== 'user' || entry.bullet === '') continue;
57+
const summary = summarizeStickyUserMessage(entry.content);
58+
if (summary === '') continue;
59+
const offset = Math.min(Math.max(0, entry.contentOffset ?? 0), height);
60+
const firstLineY = entryY + offset;
61+
const judgment = { index, summary, targetY: firstLineY + 1 };
62+
latest = judgment;
63+
if (firstLineY < scrollTop) {
64+
judgment.summary = summarizeStickyUserMessage(
65+
entry.content,
66+
countScrolledOutLines(entry.lineHeights, scrollTop - firstLineY),
67+
);
68+
pinned = judgment;
69+
}
70+
}
71+
if (following) return pinned !== null && pinned === latest ? pinned : null;
72+
return pinned;
73+
}

0 commit comments

Comments
 (0)