feat(kimi-code): report native scrollback seam from transcript container

This commit is contained in:
liruifengv 2026-07-01 11:33:12 +08:00
parent 7a2a7e5560
commit 9c166d8d58
7 changed files with 145 additions and 18 deletions

View file

@ -36,6 +36,10 @@ export class GutterContainer extends Container {
super.invalidate();
}
protected onChildRendered(_child: Component, _startRow: number, _lineCount: number): void {
// no-op by default; subclasses (TranscriptContainer) override to observe layout
}
override render(width: number): string[] {
const inner = Math.max(1, width - this.leftPad - this.rightPad);
const lead = ' '.repeat(this.leftPad);
@ -53,6 +57,7 @@ export class GutterContainer extends Container {
let allReused = cacheValid;
let i = 0;
let rowOffset = 0;
for (const child of this.children) {
const lines = child.render(inner);
childRefs.push(child);
@ -64,6 +69,8 @@ export class GutterContainer extends Container {
allReused = false;
prefixed.push(lines.map((line) => lead + line));
}
this.onChildRendered(child, rowOffset, lines.length);
rowOffset += lines.length;
i++;
}

View file

@ -0,0 +1,31 @@
import type { Component, NativeScrollbackLiveRegion } from '@moonshot-ai/pi-tui';
import { isTurnBoundaryComponent } from '#/tui/utils/transcript-component-metadata';
import { GutterContainer } from './gutter-container';
/**
* Transcript container that reports the native-scrollback live-region seam to
* the ledger engine. The live region starts at the beginning of the current
* (last) turn: everything before that row is byte-stable committed history,
* everything from that row onward is live and may change between renders.
*/
export class TranscriptContainer extends GutterContainer implements NativeScrollbackLiveRegion {
private liveRegionStart: number | undefined;
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.liveRegionStart;
}
protected override onChildRendered(child: Component, startRow: number, _lineCount: number): void {
if (isTurnBoundaryComponent(child)) {
// Last boundary wins — the live region starts at the current (last) turn.
this.liveRegionStart = startRow;
}
}
override render(width: number): string[] {
this.liveRegionStart = undefined;
return super.render(width);
}
}

View file

@ -137,7 +137,7 @@ import { installTerminalFocusTracking } from './utils/terminal-focus';
import { notifyTerminalOnce } from './utils/terminal-notification';
import { installTerminalThemeTracking } from './utils/terminal-theme';
import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard';
import { getTranscriptComponentEntry, markTranscriptComponent } from './utils/transcript-component-metadata';
import { isTurnBoundaryComponent, markTranscriptComponent } from './utils/transcript-component-metadata';
import {
TRANSCRIPT_EXPAND_TURNS,
TRANSCRIPT_HYSTERESIS,
@ -1818,16 +1818,6 @@ export class KimiTUI {
this.renderWelcome();
}
private isTurnBoundaryComponent(child: Component): boolean {
if (!(child instanceof UserMessageComponent)) return false;
const entry = getTranscriptComponentEntry(child);
if (entry === undefined) return false;
// Live user messages have an undefined turnId; replayed user messages get a
// `replay:N` turnId. Both start a new turn. Steer messages carry a defined
// non-replay turnId and are not boundaries.
return entry.turnId === undefined || entry.turnId.startsWith('replay:');
}
private trimTranscriptWindow(): boolean {
if (!TRANSCRIPT_WINDOW_ENABLED || TRANSCRIPT_MAX_TURNS <= 0) return false;
// Session replay already caps history to its own turn limit; trimming during
@ -1841,7 +1831,7 @@ export class KimiTUI {
// the rest of the turn would be left behind.
const boundaries: number[] = [];
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
if (isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
}
const turns = groupTurns(this.state.transcriptEntries);
@ -1861,7 +1851,7 @@ export class KimiTUI {
let boundariesSeen = 0;
let cutoff = 0;
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) {
if (isTurnBoundaryComponent(children[i]!)) {
if (boundariesSeen === boundariesToRemove) {
cutoff = i;
break;
@ -1894,7 +1884,7 @@ export class KimiTUI {
// Find the start of the current turn (last turn-starting user message).
let turnStart = -1;
for (let i = children.length - 1; i >= 0; i--) {
if (this.isTurnBoundaryComponent(children[i]!)) {
if (isTurnBoundaryComponent(children[i]!)) {
turnStart = i;
break;
}
@ -1963,7 +1953,7 @@ export class KimiTUI {
const boundaries: number[] = [];
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
if (isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
}
if (boundaries.length === 0) return;
@ -2221,7 +2211,7 @@ export class KimiTUI {
// components that have no entry in the metadata map.
const boundaries: number[] = [];
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
if (isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
}
const expandCutoff =
TRANSCRIPT_EXPAND_TURNS <= 0

View file

@ -8,6 +8,7 @@ import { FooterComponent } from './components/chrome/footer';
import { GutterContainer } from './components/chrome/gutter-container';
import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader';
import { TodoPanelComponent } from './components/chrome/todo-panel';
import { TranscriptContainer } from './components/chrome/transcript-container';
import type { SessionRow } from './components/dialogs/session-picker';
import { CustomEditor } from './components/editor/custom-editor';
import { CHROME_GUTTER } from './constant/rendering';
@ -27,7 +28,7 @@ import {
export interface TUIState {
ui: TUI;
terminal: ProcessTerminal;
transcriptContainer: Container;
transcriptContainer: TranscriptContainer;
activityContainer: Container;
todoPanelContainer: Container;
todoPanel: TodoPanelComponent;
@ -61,7 +62,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
const terminal = new ProcessTerminal();
const ui = new TUI(terminal);
const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
const transcriptContainer = new TranscriptContainer(CHROME_GUTTER, CHROME_GUTTER);
const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
const todoPanel = new TodoPanelComponent();

View file

@ -1,5 +1,6 @@
import type { Component } from '@moonshot-ai/pi-tui';
import { UserMessageComponent } from '../components/messages/user-message';
import type { TranscriptEntry } from '../types';
const componentEntries = new WeakMap<Component, TranscriptEntry>();
@ -13,3 +14,13 @@ export function getTranscriptComponentEntry(
): TranscriptEntry | undefined {
return componentEntries.get(component);
}
export function isTurnBoundaryComponent(child: Component): boolean {
if (!(child instanceof UserMessageComponent)) return false;
const entry = getTranscriptComponentEntry(child);
if (entry === undefined) return false;
// Live user messages have an undefined turnId; replayed user messages get a
// `replay:N` turnId. Both start a new turn. Steer messages carry a defined
// non-replay turnId and are not boundaries.
return entry.turnId === undefined || entry.turnId.startsWith('replay:');
}

View file

@ -0,0 +1,81 @@
import type { Component } from '@moonshot-ai/pi-tui';
import { describe, expect, it } from 'vitest';
import { TranscriptContainer } from '#/tui/components/chrome/transcript-container';
import { UserMessageComponent } from '#/tui/components/messages/user-message';
import { markTranscriptComponent } from '#/tui/utils/transcript-component-metadata';
class FakeChild implements Component {
constructor(private readonly lines: string[]) {}
invalidate(): void {}
render(_width: number): string[] {
return this.lines;
}
}
let nextBoundaryId = 0;
function turnBoundary(): Component {
const child = new UserMessageComponent('hello');
// turnId undefined marks a live user message, which starts a new turn.
markTranscriptComponent(child, {
id: `boundary-${nextBoundaryId++}`,
kind: 'user',
renderMode: 'markdown',
content: 'hello',
});
return child;
}
describe('TranscriptContainer', () => {
it('returns undefined when there are no children', () => {
const c = new TranscriptContainer(2, 2);
c.render(20);
expect(c.getNativeScrollbackLiveRegionStart()).toBeUndefined();
});
it('returns undefined when no child is a turn boundary', () => {
const c = new TranscriptContainer(2, 2);
c.addChild(new FakeChild(['a1', 'a2', 'a3']));
c.addChild(new FakeChild(['b1']));
c.render(20);
expect(c.getNativeScrollbackLiveRegionStart()).toBeUndefined();
});
it('returns the start row of the turn-boundary child', () => {
const c = new TranscriptContainer(2, 2);
c.addChild(new FakeChild(['a1', 'a2', 'a3'])); // 3 lines -> rows 0..2
c.addChild(new FakeChild(['b1', 'b2'])); // 2 lines -> rows 3..4
c.addChild(turnBoundary()); // starts at row 5
c.render(20);
expect(c.getNativeScrollbackLiveRegionStart()).toBe(5);
});
it('returns the start row of the LAST turn boundary (current turn wins)', () => {
const c = new TranscriptContainer(2, 2);
const inner = 20 - 2 - 2;
const first = turnBoundary(); // starts at row 0
const firstLines = first.render(inner).length;
const middle = new FakeChild(['m1', 'm2']); // 2 lines
const last = turnBoundary(); // starts at row firstLines + 2
c.addChild(first);
c.addChild(middle);
c.addChild(last);
c.render(20);
expect(c.getNativeScrollbackLiveRegionStart()).toBe(firstLines + 2);
});
it('resets to undefined after a re-render with no boundary', () => {
const c = new TranscriptContainer(2, 2);
c.addChild(turnBoundary());
c.render(20);
expect(c.getNativeScrollbackLiveRegionStart()).toBeTypeOf('number');
c.clear();
c.addChild(new FakeChild(['x1']));
c.render(20);
expect(c.getNativeScrollbackLiveRegionStart()).toBeUndefined();
});
});

View file

@ -112,3 +112,9 @@ export {
} from "./tui.ts";
// Utilities
export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts";
// Ledger scrollback seam types
export type {
NativeScrollbackCommittedRows,
NativeScrollbackLiveRegion,
RenderStablePrefix,
} from "./ledger/seam.ts";