diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index 72b1e94b1..c0c91789f 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -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++; } diff --git a/apps/kimi-code/src/tui/components/chrome/transcript-container.ts b/apps/kimi-code/src/tui/components/chrome/transcript-container.ts new file mode 100644 index 000000000..35aba062f --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/transcript-container.ts @@ -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); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 382457a83..0675f9d71 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 5a554f2f5..c56b6d077 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -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(); diff --git a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts index 94cb45693..66201c0f9 100644 --- a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts +++ b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts @@ -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(); @@ -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:'); +} diff --git a/apps/kimi-code/test/tui/components/chrome/transcript-container.test.ts b/apps/kimi-code/test/tui/components/chrome/transcript-container.test.ts new file mode 100644 index 000000000..41f59db21 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/transcript-container.test.ts @@ -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(); + }); +}); diff --git a/packages/pi-tui/src/index.ts b/packages/pi-tui/src/index.ts index 4e76b1079..4af99e5e0 100644 --- a/packages/pi-tui/src/index.ts +++ b/packages/pi-tui/src/index.ts @@ -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";