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
5 changes: 5 additions & 0 deletions .changeset/sticky-user-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add sticky user message summaries in fullscreen mode; click a summary to jump to the original message.
5 changes: 5 additions & 0 deletions .changeset/user-message-bullet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Replace the user message bullet ✨ with ❯.
5 changes: 5 additions & 0 deletions .changeset/viewport-layout-effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": minor
---

Add fullscreen layout effects that settle render requests before terminal output.
31 changes: 30 additions & 1 deletion apps/kimi-code/src/tui/components/chrome/gutter-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,14 @@ interface TranscriptRenderCache {
out: string[];
}

export interface GutterLayout {
readonly contentWidth: number;
readonly children: readonly { readonly component: Component; readonly height: number }[];
}

export class GutterContainer extends Container {
private renderCache: TranscriptRenderCache | undefined;
private renderedLayout: GutterLayout | undefined;
private unhandledClick: ((index: number) => TuiMouseEventResult | undefined) | undefined;

setUnhandledClick(handler: (index: number) => TuiMouseEventResult | undefined): void {
Expand All @@ -54,6 +60,10 @@ export class GutterContainer extends Container {
super.invalidate();
}

getRenderedLayout(): GutterLayout | undefined {
return this.renderedLayout;
}

override render(width: number): string[] {
const inner = Math.max(1, width - this.leftPad - this.rightPad);
const lead = ' '.repeat(this.leftPad);
Expand All @@ -69,13 +79,22 @@ export class GutterContainer extends Container {
const childRenderRefs: string[][] = [];
const prefixed: string[][] = [];
let allReused = cacheValid;
const previousLayout = this.renderedLayout;
let sameLayout =
previousLayout?.contentWidth === inner &&
previousLayout.children.length === this.children.length;

let i = 0;
for (const child of this.children) {
const lines = child.render(inner);
sameLayout =
sameLayout &&
previousLayout!.children[i]!.component === child &&
previousLayout!.children[i]!.height === lines.length;
childRefs.push(child);
childRenderRefs.push(lines);
const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines;
const reused =
cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines;
if (reused) {
prefixed.push(cache.prefixed[i]!);
} else {
Expand All @@ -87,6 +106,16 @@ export class GutterContainer extends Container {
i++;
}

if (!sameLayout) {
this.renderedLayout = {
contentWidth: inner,
children: childRefs.map((component, index) => ({
component,
height: childRenderRefs[index]!.length,
})),
};
}

let out: string[];
if (allReused) {
out = cache!.out;
Expand Down
51 changes: 51 additions & 0 deletions apps/kimi-code/src/tui/components/messages/sticky-user-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
truncateToWidth,
type Component,
type TuiMouseEvent,
type TuiMouseEventResult,
} from '@moonshot-ai/pi-tui';

import { TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering';
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import type { StickyUserMessageJudgment } from '#/tui/utils/sticky-user-message';

export class StickyUserMessageComponent implements Component {
private judgment: StickyUserMessageJudgment | null = null;

constructor(private readonly scrollTo: (y: number) => void) {}

setJudgment(judgment: StickyUserMessageJudgment | null): boolean {
if (
this.judgment?.component === judgment?.component &&
this.judgment?.summary === judgment?.summary &&
this.judgment?.targetY === judgment?.targetY
) {
return false;
}
this.judgment = judgment;
return true;
}

invalidate(): void {}

render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (this.judgment === null || safeWidth <= 0) return [];

const line = truncateToWidth(
USER_MESSAGE_BULLET + this.judgment.summary,
safeWidth,
TRUNCATION_ELLIPSIS,
);
return [currentTheme.boldFg('roleUser', line)];
}

handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type === 'click' && event.button === 'left' && this.judgment !== null) {
this.scrollTo(this.judgment.targetY);
return { handled: true };
}
return undefined;
}
}
71 changes: 71 additions & 0 deletions apps/kimi-code/src/tui/components/messages/transcript-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { ScrollView, VStack, type Component, type TuiMouseEvent } from '@moonshot-ai/pi-tui';

import { GutterContainer } from '#/tui/components/chrome/gutter-container';
import { StickyUserMessageComponent } from '#/tui/components/messages/sticky-user-message';
import { CHROME_GUTTER } from '#/tui/constant/rendering';
import { StickyUserMessageIndex } from '#/tui/utils/sticky-user-message';

export class TranscriptView extends VStack {
private readonly scrollView: ScrollView;
private readonly pill: StickyUserMessageComponent;
private readonly messages = new StickyUserMessageIndex();
private paddingTop = 1;

constructor(private readonly transcript: GutterContainer) {
super();
let cachedBody: string[] | undefined;
let paddedBody: string[] | undefined;
const content = {
children: [transcript],
render: (width: number) => {
const lines = transcript.render(width);
if (cachedBody !== lines) {
cachedBody = lines;
paddedBody = undefined;
}
if (this.paddingTop === 0) return lines;
return (paddedBody ??= ['', ...lines]);
},
invalidate: () => {
transcript.invalidate();
},
handleMouse: (event: TuiMouseEvent) => {
if (event.y < this.paddingTop) return undefined;
return transcript.handleMouse({
...event,
y: event.y - this.paddingTop,
height: event.height - this.paddingTop,
});
},
} satisfies Component & { children: Component[] };
this.scrollView = new ScrollView(content, {
follow: 'end',
primary: true,
overscroll: 'chain',
scrollbar: 'auto',
});
this.pill = new StickyUserMessageComponent((y) => {
this.scrollView.scrollTo(y);
});
const pillContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
pillContainer.addChild(this.pill);
this.addChild(pillContainer, { shrink: 0, minSize: 0 });
this.addChild(this.scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 0 });
}

updateStickyMessage(): boolean {
const layout = this.transcript.getRenderedLayout();
if (!layout) return false;
const height = this.scrollView.viewportHeight + (this.paddingTop === 0 ? 1 : 0);
const judgment =
height < 2
? null
: this.messages.getJudgment(
layout,
this.scrollView.scrollTop,
this.scrollView.isFollowingEnd,
);
this.paddingTop = judgment === null ? 1 : 0;
return this.pill.setJudgment(judgment);
}
}
24 changes: 23 additions & 1 deletion apps/kimi-code/src/tui/components/messages/user-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@
* Renders a user message in the transcript.
*/

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

import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
Expand All @@ -11,6 +18,21 @@ import type { ImageAttachment } from '#/tui/utils/image-attachment-store';
import { markOsc133Zone } from '#/tui/utils/osc133';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';

export function firstContentLineIndex(lines: string[]): number {
let first = 0;
while (first < lines.length && lines[first]!.trim() === '') first++;
return first;
}

export function userMessageLineHeights(text: string, width: number, bullet?: string): number[] {
const marker = bullet ?? USER_MESSAGE_BULLET;
const contentWidth = Math.max(1, width - visibleWidth(marker));
const lines = text.split(/\r?\n/);
return lines
.slice(firstContentLineIndex(lines))
.map((line) => wrapTextWithAnsi(line.replaceAll('\t', ' '), contentWidth).length);
}

export class UserMessageComponent implements Component {
private text: string;
private readonly bullet?: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/constant/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export const STATUS_BULLET = '● ';

// Shared transcript markers. Keep widths stable because message wrapping
// assumes the marker occupies the leading cells.
export const USER_MESSAGE_BULLET = '✨ ';
export const USER_MESSAGE_BULLET = '❯ ';
Comment thread
Grapedge marked this conversation as resolved.
export const SUCCESS_MARK = '✓ ';
export const FAILURE_MARK = '✗ ';

Expand Down
26 changes: 15 additions & 11 deletions apps/kimi-code/src/tui/tui-state.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import {
Container,
ProcessTerminal,
ScrollView,
TuiAltScreen,
TuiMainScreen,
VStack,
type TUI,
} from '@moonshot-ai/pi-tui';

import { TranscriptView } from '#/tui/components/messages/transcript-view';
import { clipboard } from '#/utils/clipboard/clipboard-native';
import { openUrl } from '#/utils/open-url';

import { FooterComponent } from './components/chrome/footer';import { GutterContainer } from './components/chrome/gutter-container';
import { FooterComponent } from './components/chrome/footer';
import { GutterContainer } from './components/chrome/gutter-container';
import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader';
import { NotifyPanelComponent } from './components/chrome/notify-panel';
import { TodoPanelComponent } from './components/chrome/todo-panel';
Expand All @@ -21,8 +22,6 @@ import { DEFAULT_MARKDOWN_CONFIG, DEFAULT_TUI_CONFIG } from './config';
import { CHROME_GUTTER } from './constant/rendering';
import type { TasksBrowserState } from './controllers/tasks-browser';
import { currentTheme, type Theme } from './theme';
import { setMarkdownAltScreenActive, setMarkdownMermaidMode, setMarkdownRenderLatex, setMarkdownRenderRequester } from './utils/markdown-options';
import { createTerminalState, type TerminalState } from './utils/terminal-state';
import {
INITIAL_LIVE_PANE,
type AppState,
Expand All @@ -32,6 +31,13 @@ import {
type TranscriptEntry,
type TUIStartupState,
} from './types';
import {
setMarkdownAltScreenActive,
setMarkdownMermaidMode,
setMarkdownRenderLatex,
setMarkdownRenderRequester,
} from './utils/markdown-options';
import { createTerminalState, type TerminalState } from './utils/terminal-state';

export interface TUIState {
ui: TUI;
Expand Down Expand Up @@ -154,12 +160,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
// from basis 0 and grows; the dock keeps its intrinsic height, with the
// editor never squeezed below its 3 rows (top border / input / bottom
// border) and the footer below 1 — otherwise the box outline gets clipped.
const scrollView = new ScrollView(transcriptContainer, {
follow: 'end',
primary: true,
overscroll: 'chain',
scrollbar: 'auto',
});
const transcriptView = new TranscriptView(transcriptContainer);
dockContainer = new VStack();
dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 });
dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 });
Expand All @@ -169,9 +170,12 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
dockContainer.addChild(surveyContainer, { shrink: 0, minSize: 0 });
dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 });
const root = new VStack();
root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 });
root.addChild(transcriptView, { basis: 0, grow: 1, shrink: 1, minSize: 1 });
root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 });
ui.setLayoutRoot(root);
ui.addLayoutEffect(() => {
if (ui.getLayoutRoot() === root && transcriptView.updateStickyMessage()) ui.requestRender();
});
}

return {
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export interface TranscriptEntry {
modelText?: boolean;
color?: ColorToken;
detail?: string;
/** 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). */
/** 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). */
bullet?: string;
toolCallData?: ToolCallBlockData;
backgroundAgentStatus?: BackgroundAgentStatusData;
Expand Down
Loading
Loading