diff --git a/.changeset/pi-tui-viewport-state.md b/.changeset/pi-tui-viewport-state.md new file mode 100644 index 0000000000..3a4cea0cf9 --- /dev/null +++ b/.changeset/pi-tui-viewport-state.md @@ -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. diff --git a/.changeset/pin-last-user-message.md b/.changeset/pin-last-user-message.md new file mode 100644 index 0000000000..4cd4edf82b --- /dev/null +++ b/.changeset/pin-last-user-message.md @@ -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`. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 2422dc5648..79ac51a68d 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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, }; diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 2c0010334b..227c8354da 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -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, diff --git a/apps/kimi-code/src/tui/components/messages/pinned-user-message.ts b/apps/kimi-code/src/tui/components/messages/pinned-user-message.ts new file mode 100644 index 0000000000..fe100b4dfb --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/pinned-user-message.ts @@ -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; + } +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 36f43ba07b..7b766ac133 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -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(), @@ -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, @@ -101,6 +103,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -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, @@ -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 diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 834bd77771..c32c9543ce 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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'; @@ -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 { @@ -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, @@ -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, @@ -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 | undefined; @@ -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 @@ -1337,6 +1375,7 @@ export class KimiTUI { content: input, imageAttachmentIds, }); + this.pinLastUserMessage(input); this.beginSessionRequest(); @@ -1449,6 +1488,7 @@ export class KimiTUI { ? item.imageAttachmentIds : undefined, }); + this.pinLastUserMessage(item.text); } void session.steer(combineSteerInput(input)).catch((error: unknown) => { @@ -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(); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 29cc57bff5..83075642c9 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -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. */ diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f51b..f68badd32f 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -162,6 +162,7 @@ function tuiConfig(overrides: Partial = {}): TuiConfig { return { theme: 'auto', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 3d8b2ed382..4259faa219 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -32,6 +32,7 @@ function makeStartupInput(): KimiTUIStartupInput { tuiConfig: { theme: 'dark', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index b584c33d63..7e5b9fb20c 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -43,6 +43,7 @@ describe('update preference commands', () => { theme: 'auto', editorCommand: null, disablePasteBurst: false, + pinLastUserMessage: true, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, }); diff --git a/apps/kimi-code/test/tui/components/messages/pinned-user-message.test.ts b/apps/kimi-code/test/tui/components/messages/pinned-user-message.test.ts new file mode 100644 index 0000000000..ed706e389e --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/pinned-user-message.test.ts @@ -0,0 +1,106 @@ +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { PinnedUserMessageComponent } from '#/tui/components/messages/pinned-user-message'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +/** Minimal TUI stub: the component only reads scroll state. */ +function makeTuiStub(viewportTop: number): { tui: TUI; setViewportTop(top: number): void } { + const state = { viewportTop }; + return { + tui: { + getViewportTop: () => state.viewportTop, + getContentHeight: () => 0, + } as unknown as TUI, + setViewportTop: (top: number) => { + state.viewportTop = top; + }, + }; +} + +describe('PinnedUserMessageComponent', () => { + it('renders nothing before any message is pinned', () => { + const { tui } = makeTuiStub(100); + const component = new PinnedUserMessageComponent(tui, () => true); + + expect(component.render(80)).toEqual([]); + }); + + it('stays hidden while the original message is still inside the viewport', () => { + // anchorLine sits one past the message's last buffer line: the pin only + // appears once the viewport top reaches it. + const { tui } = makeTuiStub(9); + const component = new PinnedUserMessageComponent(tui, () => true); + component.setMessage('hello world', 10); + + expect(component.render(80)).toEqual([]); + }); + + it('shows the pinned message with a divider once it scrolled above the viewport', () => { + const { tui, setViewportTop } = makeTuiStub(0); + const component = new PinnedUserMessageComponent(tui, () => true); + component.setMessage('add the incident to the changelog', 10); + + setViewportTop(100); + const lines = component.render(80); + + expect(lines).toHaveLength(2); + const plain = stripAnsi(lines[0]!); + expect(plain).toContain('✨'); + expect(plain).toContain('add the incident to the changelog'); + // Full-width rows: the transcript underneath is fully occluded. + expect(visibleWidth(lines[0]!)).toBe(80); + // A thin rule separates the pin from the scrolling transcript. + expect(stripAnsi(lines[1]!)).toBe('─'.repeat(80)); + }); + + it('caps long messages at three lines with an ellipsis', () => { + const { tui } = makeTuiStub(1000); + const component = new PinnedUserMessageComponent(tui, () => true); + component.setMessage( + 'word '.repeat(200).trim(), + 10, + ); + + const lines = component.render(40); + + expect(lines).toHaveLength(4); + expect(stripAnsi(lines[2]!)).toContain('…'); + expect(stripAnsi(lines[3]!)).toBe('─'.repeat(40)); + for (const line of lines) { + expect(visibleWidth(line)).toBe(40); + } + }); + + it('renders nothing when the feature is disabled', () => { + const { tui } = makeTuiStub(1000); + const component = new PinnedUserMessageComponent(tui, () => false); + component.setMessage('hello world', 10); + + expect(component.render(80)).toEqual([]); + }); + + it('hides again after clear()', () => { + const { tui } = makeTuiStub(1000); + const component = new PinnedUserMessageComponent(tui, () => true); + component.setMessage('hello world', 10); + expect(component.render(80)).not.toEqual([]); + + component.clear(); + expect(component.render(80)).toEqual([]); + }); + + it('updates the snapshot when a new message is pinned', () => { + const { tui } = makeTuiStub(1000); + const component = new PinnedUserMessageComponent(tui, () => true); + component.setMessage('first message', 10); + component.setMessage('second message', 500); + + const out = stripAnsi(component.render(80).join('\n')); + expect(out).toContain('second message'); + expect(out).not.toContain('first message'); + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 664fda616f..5b729b4e7d 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -60,6 +60,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -76,6 +77,15 @@ disable_paste_burst = true expect(config.disablePasteBurst).toBe(true); }); + it('parses pin_last_user_message', () => { + const config = parseTuiConfig(` +theme = "dark" +pin_last_user_message = false +`); + + expect(config.pinLastUserMessage).toBe(false); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -85,6 +95,7 @@ command = " " expect(config).toEqual({ theme: 'auto', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -118,6 +129,7 @@ command = " " { theme: 'light', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -129,6 +141,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -142,6 +155,7 @@ command = " " { theme, disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, + pinLastUserMessage: DEFAULT_TUI_CONFIG.pinLastUserMessage, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index b46eab17bf..f14507a396 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -25,6 +25,7 @@ import { agentSwarmGridHeightForTerminalRows, } from '#/tui/components/messages/agent-swarm-progress'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { PinnedUserMessageComponent } from '#/tui/components/messages/pinned-user-message'; import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { @@ -146,6 +147,7 @@ function makeStartupInput(): KimiTUIStartupInput { tuiConfig: { theme: 'dark', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -5879,3 +5881,91 @@ describe('transcript step and assistant folding', () => { expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); }); }); + +describe('pinned last user message', () => { + function getPinned(driver: MessageDriver): PinnedUserMessageComponent | undefined { + return (driver as unknown as { pinnedUserMessage?: PinnedUserMessageComponent }) + .pinnedUserMessage; + } + + it('mounts the pinned overlay and snapshots the sent message', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('first pin probe'); + + expect(driver.state.ui.hasOverlay()).toBe(true); + const pinned = getPinned(driver); + expect(pinned).toBeInstanceOf(PinnedUserMessageComponent); + + // Simulate the transcript having scrolled far past the message. + vi.spyOn(driver.state.ui, 'getViewportTop').mockReturnValue(1000); + const band = stripSgr(pinned!.render(120).join('\n')); + expect(band).toContain('first pin probe'); + }); + + it('keeps a single overlay and updates the snapshot on the next send', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('first pin probe'); + // Let the first turn finish so the second message sends instead of queueing. + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + vi.fn(), + ); + driver.handleUserInput('second pin probe'); + + vi.spyOn(driver.state.ui, 'getViewportTop').mockReturnValue(1000); + const band = stripSgr(getPinned(driver)!.render(120).join('\n')); + expect(band).toContain('second pin probe'); + expect(band).not.toContain('first pin probe'); + }); + + it('clears the pin on /clear', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('first pin probe'); + // Finish the turn so /clear (an alias of /new) is allowed to proceed. + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + vi.fn(), + ); + vi.spyOn(driver.state.ui, 'getViewportTop').mockReturnValue(1000); + expect(getPinned(driver)!.render(120)).not.toEqual([]); + + driver.handleUserInput('/clear'); + await vi.waitFor(() => { + expect(getPinned(driver)!.render(120)).toEqual([]); + }); + }); + + it('does not mount the overlay when pin_last_user_message is off', async () => { + const session = makeSession(); + const harness = makeHarness(session); + const input = makeStartupInput(); + const driver = new KimiTUI(harness as never, { + ...input, + tuiConfig: { ...input.tuiConfig, pinLastUserMessage: false }, + }) as unknown as MessageDriver; + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); + driver.persistInputHistory = vi.fn(async () => {}); + await driver.init(); + + driver.handleUserInput('first pin probe'); + + expect(driver.state.ui.hasOverlay()).toBe(false); + expect(getPinned(driver)).toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index daaaa79e12..7b234f7ad2 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -92,6 +92,7 @@ function makeStartupInput( tuiConfig: { theme: 'dark', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index d944a485f8..8fd410e4db 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -62,6 +62,7 @@ function makeStartupInput(): KimiTUIStartupInput { tuiConfig: { theme: 'dark', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 0cf9c76c81..e87e4a1e9d 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -28,6 +28,7 @@ function makeStartupInput(): KimiTUIStartupInput { tuiConfig: { theme: 'dark', disablePasteBurst: false, + pinLastUserMessage: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index b671922578..aa8afcbe17 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -388,6 +388,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | +| `pin_last_user_message` | `boolean` | `true` | Keep your last sent message pinned at the top of the viewport once it scrolls off; set to `false` to disable | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | @@ -399,6 +400,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name disable_paste_burst = false # true disables non-bracketed paste-burst fallback +pin_last_user_message = true # false hides the pinned last sent message [editor] command = "" # empty uses $VISUAL / $EDITOR diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 478959acf6..afec52cc82 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -388,6 +388,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | --- | --- | --- | --- | | `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes.md)的名字 | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | +| `pin_last_user_message` | `boolean` | `true` | 你发出的最新消息滚出屏幕后,将其固定在视口顶部显示;设为 `false` 可关闭 | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | @@ -399,6 +400,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 +pin_last_user_message = true # false 表示不固定显示最后发送的消息 [editor] command = "" # 留空则使用 $VISUAL / $EDITOR diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index e4556a8f47..fa7ed12be6 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -629,6 +629,23 @@ export class TUI extends Container { return this.overlayStack.some((o) => this.isOverlayVisible(o)); } + /** + * Line index of the top of the visible viewport within the rendered buffer, + * as of the last completed frame. 0 while the content still fits the screen; + * grows as content scrolls into terminal scrollback. + */ + getViewportTop(): number { + return this.previousViewportTop; + } + + /** + * Number of lines in the rendered buffer as of the last completed frame + * (0 before the first render). + */ + getContentHeight(): number { + return this.previousLines.length; + } + /** Check if an overlay entry is currently visible */ private isOverlayVisible(entry: OverlayStackEntry): boolean { if (entry.hidden) return false; diff --git a/packages/pi-tui/test/tui-viewport-state.test.ts b/packages/pi-tui/test/tui-viewport-state.test.ts new file mode 100644 index 0000000000..bcfb410e1f --- /dev/null +++ b/packages/pi-tui/test/tui-viewport-state.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class SimpleContent implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + invalidate() {} +} + +function makeLines(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Line ${i + 1}`); +} + +describe("TUI viewport state accessors", () => { + it("reports zero scroll when content fits the screen", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.addChild(new SimpleContent(makeLines(3))); + + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(tui.getContentHeight(), 3); + assert.strictEqual(tui.getViewportTop(), 0); + + tui.stop(); + }); + + it("reports scroll position once content overflows the screen", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.addChild(new SimpleContent(makeLines(100))); + + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(tui.getContentHeight(), 100); + assert.strictEqual(tui.getViewportTop(), 100 - 24); + + tui.stop(); + }); + + it("tracks viewport growth as lines are appended", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const content = new SimpleContent(makeLines(30)); + tui.addChild(content); + + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.getViewportTop(); + + // Simulate transcript growth: 10 more lines appear at the bottom. + (content as { lines: string[] }).lines = makeLines(40); + tui.requestRender(); + await terminal.waitForRender(); + + assert.strictEqual(tui.getContentHeight(), 40); + assert.ok( + tui.getViewportTop() > topBefore, + `viewportTop should grow after append (${tui.getViewportTop()} > ${topBefore})`, + ); + + tui.stop(); + }); +});