Skip to content

Commit fcf6c73

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 fcf6c73

12 files changed

Lines changed: 1011 additions & 12 deletions

File tree

‎.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: 28 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,
@@ -160,6 +163,30 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
160163
overscroll: 'chain',
161164
scrollbar: 'auto',
162165
});
166+
const stickyUserMessage = new StickyUserMessageComponent({
167+
measure: (width) =>
168+
transcriptContainer.children.map((child) => {
169+
const entry = getTranscriptComponentEntry(child);
170+
const lines = child.render(width);
171+
const isUserMessage = entry?.kind === 'user' && entry.bullet !== '';
172+
return {
173+
kind: entry?.kind,
174+
bullet: entry?.bullet,
175+
content: entry?.content ?? '',
176+
height: lines.length,
177+
contentOffset: isUserMessage ? userMessageContentOffset(entry.content) : undefined,
178+
lineHeights: isUserMessage
179+
? userMessageLineHeights(entry.content, width, entry.bullet)
180+
: [],
181+
};
182+
}),
183+
scrollState: () => ({ scrollTop: ui.viewportTop, following: ui.isFollowingOutput }),
184+
scrollTo: (y) => {
185+
scrollView.scrollTo(y);
186+
},
187+
});
188+
const stickyUserMessageContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
189+
stickyUserMessageContainer.addChild(stickyUserMessage);
163190
dockContainer = new VStack();
164191
dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 });
165192
dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 });
@@ -169,6 +196,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
169196
dockContainer.addChild(surveyContainer, { shrink: 0, minSize: 0 });
170197
dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 });
171198
const root = new VStack();
199+
root.addChild(stickyUserMessageContainer, { shrink: 0, minSize: 0 });
172200
root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 });
173201
root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 });
174202
ui.setLayoutRoot(root);
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)