Skip to content
Closed
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/pi-tui-viewport-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Add read-only accessors for the rendered buffer's viewport top and content height so components can read the engine's scroll state.
5 changes: 5 additions & 0 deletions .changeset/pin-last-user-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Pin your last sent message at the top of the terminal viewport once it scrolls off, so the prompt behind the current work stays visible. Opt out with `pin_last_user_message = false` in `tui.toml`.
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig {
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
pinLastUserMessage:
host.state.appState.pinLastUserMessage ?? DEFAULT_TUI_CONFIG.pinLastUserMessage,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
};
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export async function applyReloadedTuiConfig(
host.setAppState({
editorCommand: config.editorCommand,
disablePasteBurst: config.disablePasteBurst,
pinLastUserMessage: config.pinLastUserMessage,
notifications: config.notifications,
upgrade: config.upgrade,
statusLine: config.statusLine,
Expand Down
127 changes: 127 additions & 0 deletions apps/kimi-code/src/tui/components/messages/pinned-user-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Renders the user's most recently sent message as a band pinned to the top
* of the viewport (mounted as a non-capturing, top-anchored overlay), so the
* prompt that kicked off the current work stays visible while the transcript
* scrolls underneath — same idea as Claude Code's pinned prompt.
*
* The component snapshots the message text (the transcript window may trim
* the original UserMessageComponent) and stays invisible until the original
* message has scrolled above the viewport, so it never duplicates content
* that is still on screen. Visibility lags one frame because it is derived
* from the engine's last-completed frame (`TUI.getViewportTop`), which is
* imperceptible in practice.
*/

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

import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

/** Maximum band height in rows; longer messages are ellipsized. */
const MAX_LINES = 3;

const EMPTY_LINES: string[] = [];

export class PinnedUserMessageComponent implements Component {
private readonly tui: TUI;
private readonly isEnabled: () => boolean;
private text = '';
/** Root-buffer line index one past the message's last line, measured from
* the transcript container's rendered height right after the append. The
* pin shows once the viewport top passes it. */
private anchorLine = 0;
private generation = 0;

private wrapCache: { width: number; lines: string[] } | undefined;
private bandCache:
| { width: number; generation: number; visible: boolean; lines: string[] }
| undefined;

constructor(tui: TUI, isEnabled: () => boolean) {
this.tui = tui;
this.isEnabled = isEnabled;
}

/** Snapshot a freshly sent user message. `anchorLine` should be the
* transcript container's rendered height right after the append (root-buffer
* line index one past the message's last line). */
setMessage(text: string, anchorLine: number): void {
this.text = text;
this.anchorLine = anchorLine;
this.generation += 1;
this.wrapCache = undefined;
this.bandCache = undefined;
}

/** Hide the pin (session clear / new session). */
clear(): void {
if (this.text.length === 0) return;
this.text = '';
this.generation += 1;
this.wrapCache = undefined;
this.bandCache = undefined;
}

invalidate(): void {
// Theme change: re-dye from the current palette on the next render.
this.bandCache = undefined;
}

/** Plain-text wrap of the message at `contentWidth`, cached per width. */
private wrapLines(contentWidth: number): string[] {
if (this.wrapCache !== undefined && this.wrapCache.width === contentWidth) {
return this.wrapCache.lines;
}
const lines = new Text(this.text, 0, 0).render(contentWidth);
this.wrapCache = { width: contentWidth, lines };
return lines;
}

render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0 || this.text.length === 0 || !this.isEnabled()) return EMPTY_LINES;

const bullet = USER_MESSAGE_BULLET;
const bulletWidth = visibleWidth(bullet);
const contentWidth = Math.max(1, safeWidth - bulletWidth);

// anchorLine sits one past the message's last buffer line, so the pin
// appears exactly when the original entry has fully scrolled off.
const visible = this.tui.getViewportTop() >= this.anchorLine;

if (
this.bandCache !== undefined &&
this.bandCache.width === safeWidth &&
this.bandCache.generation === this.generation &&
this.bandCache.visible === visible
) {
return this.bandCache.lines;
}

const wrapped = this.wrapLines(contentWidth);

let lines: string[] = EMPTY_LINES;
if (visible) {
let capped = wrapped;
if (wrapped.length > MAX_LINES) {
capped = wrapped.slice(0, MAX_LINES);
// Ellipsize the last visible row, guaranteeing the marker fits.
const last = truncateToWidth(capped[MAX_LINES - 1]!, Math.max(1, contentWidth - 2), '');
capped[MAX_LINES - 1] = `${last} …`;
}
// Subtle styling: kimi-blue text on the terminal's own background (the
// overlay compositor blanks the rows underneath), separated from the
// scrolling transcript by a thin muted rule.
lines = capped.map((line, index) => {
const prefix = index === 0 ? bullet : ' '.repeat(bulletWidth);
const styled = currentTheme.fg('primary', prefix + line);
const pad = Math.max(0, safeWidth - visibleWidth(styled));
return styled + ' '.repeat(pad);
});
lines.push(currentTheme.dimFg('textMuted', '─'.repeat(safeWidth)));
}

this.bandCache = { width: safeWidth, generation: this.generation, visible, lines };
return lines;
}
}
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = {
export const TuiConfigFileSchema = z.object({
theme: TuiThemeSchema.optional(),
disable_paste_burst: z.boolean().optional(),
pin_last_user_message: z.boolean().optional(),
editor: z
.object({
command: z.string().optional(),
Expand All @@ -76,6 +77,7 @@ export const TuiConfigFileSchema = z.object({
export const TuiConfigSchema = z.object({
theme: TuiThemeSchema,
disablePasteBurst: z.boolean(),
pinLastUserMessage: z.boolean(),
editorCommand: z.string().nullable(),
notifications: NotificationsConfigSchema,
upgrade: UpgradePreferencesSchema,
Expand All @@ -101,6 +103,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = {
export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({
theme: 'auto',
disablePasteBurst: false,
pinLastUserMessage: true,
Comment thread
lucastononro marked this conversation as resolved.
editorCommand: null,
notifications: DEFAULT_NOTIFICATIONS_CONFIG,
upgrade: DEFAULT_UPGRADE_PREFERENCES,
Expand Down Expand Up @@ -186,6 +189,7 @@ export function normalizeTuiConfig(
return TuiConfigSchema.parse({
theme: config.theme ?? DEFAULT_TUI_CONFIG.theme,
disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
pinLastUserMessage: config.pin_last_user_message ?? DEFAULT_TUI_CONFIG.pinLastUserMessage,
editorCommand: command === undefined || command.length === 0 ? null : command,
notifications: {
enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled,
Expand Down Expand Up @@ -234,6 +238,7 @@ export function renderTuiConfig(config: TuiConfig): string {

theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name
disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback
pin_last_user_message = ${String(config.pinLastUserMessage)} # true pins your last sent message at the top of the viewport

[editor]
command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR
Expand Down
45 changes: 43 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import {
type Focusable,
getCapabilities,
Spacer,
} from '@moonshot-ai/pi-tui';
import { resolve } from 'pathe';
} from '@moonshot-ai/pi-tui';import { resolve } from 'pathe';

import type { CLIOptions } from '#/cli/options';
import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index';
Expand Down Expand Up @@ -79,6 +78,7 @@ import {
GoalSetMessageComponent,
} from './components/messages/goal-panel';
import { PluginCommandComponent } from './components/messages/plugin-command';
import { PinnedUserMessageComponent } from './components/messages/pinned-user-message';
import { ShellRunComponent } from './components/messages/shell-run';
import { SkillActivationComponent } from './components/messages/skill-activation';
import {
Expand All @@ -95,6 +95,7 @@ import {
import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane';
import { QueuePaneComponent } from './components/panes/queue-pane';
import type { TuiConfig } from './config';
import { DEFAULT_TUI_CONFIG } from './config';
import {
LLM_NOT_SET_MESSAGE,
MAIN_AGENT_ID,
Expand Down Expand Up @@ -235,6 +236,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
version: input.version,
editorCommand: input.tuiConfig.editorCommand,
disablePasteBurst: input.tuiConfig.disablePasteBurst,
pinLastUserMessage: input.tuiConfig.pinLastUserMessage,
notifications: input.tuiConfig.notifications,
upgrade: input.tuiConfig.upgrade,
statusLine: input.tuiConfig.statusLine,
Expand Down Expand Up @@ -350,6 +352,12 @@ export class KimiTUI {
readonly tasksBrowserController: TasksBrowserController;
readonly editorKeyboard: EditorKeyboardController;

/** Pinned band showing the last sent user message at the top of the
* viewport. Lazily created + mounted as an overlay on the first send; the
* component self-gates its visibility from the engine's scroll state, so no
* overlay handle is kept. */
private pinnedUserMessage: PinnedUserMessageComponent | undefined;

/** Timer that auto-clears the one-shot "moved to background" footer hint. */
private detachHintClearTimer: ReturnType<typeof setTimeout> | undefined;

Expand Down Expand Up @@ -1324,6 +1332,36 @@ export class KimiTUI {
this.sessionEventHandler.requestQueuedGoalPromotion();
}

/**
* Snapshot a freshly sent user message into the pinned top-of-viewport band.
* The band stays hidden until the original transcript entry scrolls above
* the viewport, so it never duplicates on-screen content.
*/
private pinLastUserMessage(text: string): void {
if (!(this.state.appState.pinLastUserMessage ?? DEFAULT_TUI_CONFIG.pinLastUserMessage)) {
return;
}
if (this.pinnedUserMessage === undefined) {
this.pinnedUserMessage = new PinnedUserMessageComponent(
this.state.ui,
() => this.state.appState.pinLastUserMessage ?? DEFAULT_TUI_CONFIG.pinLastUserMessage,
);
this.state.ui.showOverlay(this.pinnedUserMessage, {
anchor: 'top-left',
width: '100%',
nonCapturing: true,
});
}
// Anchor at the message's actual position: the transcript is the root
// layout's first child, so its rendered height right after the append is
// one past the new entry's last buffer line. (The engine's content height
// would be a frame stale and would include the bottom chrome and overlay
// padding.) Rendering here is cheap — child render caches do the work.
const anchorLine = this.state.transcriptContainer.render(this.state.terminal.columns).length;
this.pinnedUserMessage.setMessage(text, anchorLine);
this.state.ui.requestRender();
}

private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void {
const imageAttachmentIds =
options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0
Expand All @@ -1337,6 +1375,7 @@ export class KimiTUI {
content: input,
imageAttachmentIds,
});
this.pinLastUserMessage(input);

this.beginSessionRequest();

Expand Down Expand Up @@ -1449,6 +1488,7 @@ export class KimiTUI {
? item.imageAttachmentIds
: undefined,
});
this.pinLastUserMessage(item.text);
}

void session.steer(combineSteerInput(input)).catch((error: unknown) => {
Expand Down Expand Up @@ -2068,6 +2108,7 @@ export class KimiTUI {
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
this.disposeTranscriptChildren();
this.state.transcriptContainer.clear();
this.pinnedUserMessage?.clear();
this.btwPanelController.clear();
this.clearTerminalInlineImages();
this.state.todoPanel.clear();
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export interface AppState {
editorCommand: string | null;
/** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */
disablePasteBurst?: boolean;
/** Mirrors the TUI config toggle pinning the last user message at the top of
* the viewport; defaults to true when absent from older fixtures. */
pinLastUserMessage?: boolean;
notifications: NotificationsConfig;
upgrade: UpgradePreferences;
/** Footer status line customization from tui.toml; absent means the default layout. */
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/cli/update/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ function tuiConfig(overrides: Partial<TuiConfig> = {}): TuiConfig {
return {
theme: 'auto',
disablePasteBurst: false,
pinLastUserMessage: true,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/activity-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function makeStartupInput(): KimiTUIStartupInput {
tuiConfig: {
theme: 'dark',
disablePasteBurst: false,
pinLastUserMessage: true,
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('update preference commands', () => {
theme: 'auto',
editorCommand: null,
disablePasteBurst: false,
pinLastUserMessage: true,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: false },
});
Expand Down
Loading