diff --git a/.changeset/fix-tui-dynamic-height-jitter.md b/.changeset/fix-tui-dynamic-height-jitter.md new file mode 100644 index 0000000000..3974558a42 --- /dev/null +++ b/.changeset/fix-tui-dynamic-height-jitter.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix visible jitter when dynamic-height UI components collapse during rendering. diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index b101b6d6c7..348426bb62 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -114,9 +114,19 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { export class TodoPanelComponent implements Component { private todos: readonly TodoItem[] = []; private expanded = false; + private lastRenderedLines = 0; + private placeholderLines = 0; setTodos(todos: readonly TodoItem[]): void { + const wasEmpty = this.todos.length === 0; this.todos = todos.map((t) => ({ title: t.title, status: t.status })); + if (!wasEmpty && this.todos.length === 0) { + // Cleared from non-empty: hold a placeholder at the last rendered + // height until the next AI output starts (clearPlaceholder). + this.placeholderLines = this.lastRenderedLines; + } else if (this.todos.length > 0) { + this.placeholderLines = 0; + } } getTodos(): readonly TodoItem[] { @@ -126,6 +136,11 @@ export class TodoPanelComponent implements Component { clear(): void { this.todos = []; this.expanded = false; + this.placeholderLines = 0; + } + + clearPlaceholder(): void { + this.placeholderLines = 0; } isEmpty(): boolean { @@ -148,7 +163,12 @@ export class TodoPanelComponent implements Component { invalidate(): void {} render(width: number): string[] { - if (this.todos.length === 0) return []; + if (this.todos.length === 0) { + if (this.placeholderLines > 0) { + return Array.from({ length: this.placeholderLines }, () => ''); + } + return []; + } const c = currentTheme.palette; const lines: string[] = [ chalk.hex(c.border)('─'.repeat(width)), @@ -178,6 +198,7 @@ export class TodoPanelComponent implements Component { } } + this.lastRenderedLines = lines.length; return lines.map((line) => truncateToWidth(line, width)); } } diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index c2d4bf98a9..4dfbaca96b 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -120,6 +120,7 @@ interface CustomEditorOptions { export class CustomEditor extends Editor { public onEscape?: () => void; + private wasShowingAutocomplete = false; /** * Fired for every input that is not a lone Escape. Used to disarm a pending * double-Esc so only two consecutive Escape presses trigger the shortcut. @@ -259,6 +260,13 @@ export class CustomEditor extends Editor { override render(width: number): string[] { const lines = super.render(width); + const showing = this.isShowingAutocomplete(); + if (this.wasShowingAutocomplete && !showing) { + // Autocomplete dropdown just closed: the editor returned fewer lines, + // which would pull the footer up and leave stale rows in scrollback. + this.tui.requestRender(true); + } + this.wasShowingAutocomplete = showing; if (lines.length < 3) return lines; const firstContentIdx = 1; const isBash = this.inputMode === 'bash'; diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 00ede4a269..45f2db39dc 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -156,6 +156,8 @@ export interface ClusteredDiffOptions { readonly maxLines?: number; readonly isIncomplete?: boolean; readonly expandKeyHint?: string; + /** When set, keep the tail of the body (latest changes) instead of the head. */ + readonly tail?: boolean; } interface Cluster { @@ -254,7 +256,8 @@ export function renderDiffLinesClustered( if (clusters.length === 0) return output; - const cap = maxLines !== undefined && maxLines >= 0 ? maxLines : Number.POSITIVE_INFINITY; + const cap = + maxLines !== undefined && maxLines >= 0 && !opts.tail ? maxLines : Number.POSITIVE_INFINITY; let body = 0; let prevEnd = -1; let truncated = false; @@ -305,5 +308,18 @@ export function renderDiffLinesClustered( } } + if (opts.tail && maxLines !== undefined && maxLines >= 0 && output.length > maxLines) { + const header = output[0]!; + const bodyLines = output.slice(1); + const keep = Math.max(1, maxLines - 1); + const hidden = bodyLines.length - keep; + const hint = opts.expandKeyHint ?? 'ctrl+o'; + return [ + header, + s.meta(` … ${String(hidden)} earlier lines hidden (${hint} to expand)`), + ...bodyLines.slice(bodyLines.length - keep), + ]; + } + return output; } diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 6fd1624d5b..298aa24373 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -135,6 +135,10 @@ export class AgentGroupComponent extends Container { }); if (this.shouldShowDetachHint(snapshots)) { this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } else { + // Keep a placeholder row so the group height does not shrink when the + // last running agent finishes and the hint disappears. + this.bodyContainer.addChild(new Spacer(1)); } this.lastFlushPhases.clear(); @@ -200,7 +204,13 @@ export class AgentGroupComponent extends Container { return; } if (snap.phase === 'done' || snap.phase === 'backgrounded') { - // Terminal states omit the second line. + // Keep the second line so the row count stays stable across the + // running -> done transition. latestActivity falls back to the last + // finished sub-tool ("Used {name} ({keyArg})") in terminal states, so + // it shows what the agent did last. + const activity = + snap.latestActivity ?? (snap.phase === 'done' ? 'Completed' : 'Backgrounded'); + this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); return; } // Running or not-yet-started agents show latest activity, with a fallback. diff --git a/apps/kimi-code/src/tui/components/messages/fixed-height-window.ts b/apps/kimi-code/src/tui/components/messages/fixed-height-window.ts new file mode 100644 index 0000000000..619e15fc67 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/fixed-height-window.ts @@ -0,0 +1,38 @@ +import type { Component } from '@moonshot-ai/pi-tui'; + +export interface FixedHeightWindowOptions { + height: number; + lines?: string[]; + tail?: boolean; // default true +} + +export class FixedHeightWindow implements Component { + private lines: string[]; + private readonly height: number; + private readonly tail: boolean; + + constructor(opts: FixedHeightWindowOptions) { + this.height = Math.max(0, opts.height); + this.tail = opts.tail ?? true; + this.lines = opts.lines ?? []; + } + + setLines(lines: string[]): void { + this.lines = lines; + } + + invalidate(): void {} + + render(_width: number): string[] { + if (this.height === 0) return []; + const src = this.lines; + let shown: string[]; + if (src.length > this.height) { + shown = this.tail ? src.slice(src.length - this.height) : src.slice(0, this.height); + } else { + shown = [...src]; + } + while (shown.length < this.height) shown.push(''); + return shown; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts index ca99f2e763..4c71e5bbb0 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-run.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -4,6 +4,8 @@ import { currentTheme } from '#/tui/theme'; import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; +import { FixedHeightWindow } from './fixed-height-window'; + const RUNNING_TAIL_LINES = 5; const TIMER_INTERVAL_MS = 1000; // Cap the live running buffer so a command that spews output for minutes can't @@ -105,26 +107,47 @@ export class ShellRunComponent extends Container { if (this.backgrounded) { return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; } + const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); + const dim = (s: string): string => currentTheme.fg('textDim', s); + if (!this.running) { - return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError) - .split('\n') + // Finished: show the tail of the final output in the same fixed + // window as the running view so the card height does not change. + const allLines = formatBashOutputForDisplay( + this.finalStdout, + this.finalStderr, + this.finalIsError, + ).split('\n'); + const window = new FixedHeightWindow({ + height: RUNNING_TAIL_LINES, + tail: true, + lines: allLines, + }); + const body = window + .render(80) .map((line) => ` ${line}`) .join('\n'); + const hidden = Math.max(0, allLines.length - RUNNING_TAIL_LINES); + const status = ` ${dim( + `completed${hidden > 0 ? ` · +${String(hidden)} lines` : ''} · ctrl+o to expand`, + )}`; + return `${body}\n${status}\n `; } - const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); - const dim = (s: string): string => currentTheme.fg('textDim', s); + + // Running: dim tail of the combined output + timing + hint. const trimmed = sanitizeShellOutput(this.combined).trimEnd(); - let body: string; - let extra = 0; - if (trimmed.length === 0) { - body = ` ${dim('Running…')}`; - } else { - const lines = trimmed.split('\n'); - const tail = lines.slice(-RUNNING_TAIL_LINES); - extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); - body = tail.map((line) => ` ${dim(line)}`).join('\n'); - } - const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; + const allLines = trimmed.length === 0 ? ['Running…'] : trimmed.split('\n'); + const window = new FixedHeightWindow({ + height: RUNNING_TAIL_LINES, + tail: true, + lines: allLines, + }); + const body = window + .render(80) + .map((line) => ` ${dim(line)}`) + .join('\n'); + const extra = Math.max(0, allLines.length - RUNNING_TAIL_LINES); + const timing = ` ${dim(`${extra > 0 ? `+${String(extra)} lines ` : ''}(${String(elapsed)}s)`)}`; const hint = ` ${dim('(ctrl+b to run in background)')}`; return `${body}\n${timing}\n${hint}`; } catch { diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 23a038c707..a53563b6c0 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -114,27 +114,35 @@ export class ThinkingComponent implements Component { spinner + currentTheme.fg('textDim', 'thinking...'), ...visibleLines.map((line) => MESSAGE_INDENT + line), ]; - } else { + } else if (this.expanded) { const lines: string[] = ['']; for (let i = 0; i < contentLines.length; i++) { const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - - if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { - rendered = lines; - } else { - // Leading blank + first PREVIEW_LINES content lines + hint line. - const truncated = lines.slice(0, 1 + THINKING_PREVIEW_LINES); - const remaining = contentLines.length - THINKING_PREVIEW_LINES; - const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`; - const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); - const hintWidth = Math.max(0, width - indentWidth); - truncated.push( - ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')), - ); - rendered = truncated; + rendered = lines; + } else if (contentLines.length > THINKING_PREVIEW_LINES) { + // Finalized, collapsed, long content: 2 content rows + hint (4 rows + // total), matching the live mode's collapsed height. + const first = contentLines[0] ?? ''; + const second = contentLines[1] ?? ''; + const header = + (this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT) + first; + const remaining = contentLines.length - THINKING_PREVIEW_LINES; + const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`; + const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + const tailLine = + ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')); + rendered = ['', header, MESSAGE_INDENT + second, tailLine]; + } else { + // Finalized, collapsed, short content: natural height, no blank padding. + const lines: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + lines.push(p + contentLines[i]); } + rendered = lines; } if (isRenderCacheEnabled()) { diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 9b2a2d20f1..8e89550bec 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -30,6 +30,7 @@ import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; +import { FixedHeightWindow } from './fixed-height-window'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; @@ -599,6 +600,9 @@ export class ToolCallComponent extends Container { // authoritative final state. private progressLines: string[] = []; private static readonly MAX_PROGRESS_LINES = 24; + // Height of the live progress window while a tool is running. Kept small so + // the card does not grow a tall progress region that snaps away on result. + private static readonly PROGRESS_WINDOW_LINES = 6; private liveOutput = ''; /** @@ -1518,21 +1522,27 @@ export class ToolCallComponent extends Container { private buildProgressBlock(): void { if (this.progressLines.length === 0) return; if (this.result !== undefined) return; - for (const raw of this.progressLines) { - if (raw.length === 0) { - this.addChild(new Text('', 2, 0)); - continue; - } - PROGRESS_URL_RE.lastIndex = 0; - const styled = PROGRESS_URL_RE.test(raw) - ? raw.replace(PROGRESS_URL_RE, (url) => { - const visible = currentTheme.underlineFg('warning', url); - return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; - }) - : currentTheme.dim(raw); - PROGRESS_URL_RE.lastIndex = 0; - this.addChild(new Text(styled, 2, 0)); - } + const styled = this.progressLines.map((raw) => this.styleProgressLine(raw)); + this.addChild( + new FixedHeightWindow({ + height: ToolCallComponent.PROGRESS_WINDOW_LINES, + tail: true, + lines: styled, + }), + ); + } + + private styleProgressLine(raw: string): string { + if (raw.length === 0) return ''; + PROGRESS_URL_RE.lastIndex = 0; + const styled = PROGRESS_URL_RE.test(raw) + ? raw.replace(PROGRESS_URL_RE, (url) => { + const visible = currentTheme.underlineFg('warning', url); + return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; + }) + : currentTheme.dim(raw); + PROGRESS_URL_RE.lastIndex = 0; + return ` ${styled}`; } private buildLiveOutputBlock(): void { @@ -1921,7 +1931,6 @@ export class ToolCallComponent extends Container { this.buildStreamingPreview(this.toolCall.streamingArguments); return; } - const shouldCap = this.result !== undefined && !this.expanded; if (name === 'Write') { const content = str(this.toolCall.args['content']); if (content.length === 0) return; @@ -1955,9 +1964,10 @@ export class ToolCallComponent extends Container { const newStr = str(this.toolCall.args['new_string']); if (oldStr.length === 0 && newStr.length === 0) return; const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); + const editShouldCap = !this.expanded; const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3, - ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), + ...(editShouldCap ? { maxLines: COMMAND_PREVIEW_LINES, tail: true } : {}), }); for (const line of lines) { this.addChild(new Text(line, 2, 0)); @@ -2014,6 +2024,18 @@ export class ToolCallComponent extends Container { const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } + if (allLines.length > maxLines) { + const remaining = allLines.length - scrollLines.length; + this.addChild( + new Text( + currentTheme.dim( + `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, + ), + 2, + 0, + ), + ); + } return; } if (name === 'Edit') { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 8658c7532d..f5d4932d67 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -582,6 +582,7 @@ export class StreamingUIController { onStreamingTextStart(): void { const { state } = this.host; + state.todoPanel.clearPlaceholder(); this._pendingAgentGroup = null; this._pendingReadGroup = null; const entry = { @@ -619,6 +620,7 @@ export class StreamingUIController { if (fullText.length === 0 && this._activeThinkingComponent === undefined) return; const { state } = this.host; if (this._activeThinkingComponent === undefined) { + state.todoPanel.clearPlaceholder(); this._pendingAgentGroup = null; this._pendingReadGroup = null; this._activeThinkingComponent = new ThinkingComponent( @@ -647,6 +649,7 @@ export class StreamingUIController { if (toolCall.name === 'AskUserQuestion') return; const { state } = this.host; + state.todoPanel.clearPlaceholder(); const tc = new ToolCallComponent( toolCall, undefined, diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index deb407bfc2..d4ec1c5401 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -554,6 +554,7 @@ export class SubAgentEventHandler { if (index >= 0) { children.splice(index, 1); this.host.state.transcriptContainer.invalidate(); + this.host.state.ui.requestRender(true); } this.host.updateActivityPane(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 7a423911fe..9a9b8ebc62 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2366,7 +2366,10 @@ export class KimiTUI { toggleTodoPanelExpansion(): void { this.state.todoPanel.toggleExpanded(); - this.state.ui.requestRender(); + // Expanding/collapsing the todo panel shifts the editor vertically; the + // clamped differential render leaves stale rows in scrollback. Force a full + // render, matching the Ctrl+O toggle a few lines above. + this.state.ui.requestRender(true); } private async detachRunningShellCommand(): Promise { @@ -2598,7 +2601,10 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(); + // Closing a dialog/selector shrinks the editor area; differential rendering + // leaves stale bytes in scrollback and a misplaced footer. Force a full + // render, matching the Ctrl+O expansion toggle. + this.state.ui.requestRender(true); } restoreInputText(text: string): void { diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 1384dfc0bd..bd030beb10 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -756,3 +756,26 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); + +describe('CustomEditor autocomplete full render on close', () => { + it('forces a full render when the autocomplete dropdown closes', async () => { + const tui = { + requestRender: vi.fn(), + terminal: { rows: 40, cols: 120 }, + } as unknown as TUI; + const editor = new CustomEditor(tui); + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + editor.render(80); + vi.mocked(tui.requestRender).mockClear(); + + editor.handleInput(''); // Escape closes the dropdown + expect(editor.isShowingAutocomplete()).toBe(false); + editor.render(80); + + expect(tui.requestRender).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts index 8e7214e11b..b1910e786e 100644 --- a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -1,176 +1,31 @@ -import { describe, expect, it } from 'vitest'; +import { describe, it, expect } from 'vitest'; -import { - computeDiffLines, - renderDiffLines, - renderDiffLinesClustered, -} from '#/tui/components/media/diff-preview'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +function lines(n: number, prefix = 'line'): string { + return Array.from({ length: n }, (_, i) => `${prefix} ${i + 1}`).join('\n'); } -describe('computeDiffLines', () => { - it('renders a complete diff when isIncomplete is false', () => { - const lines = computeDiffLines('A\nB\nC\nD', 'A\nB', 1, 1, false); - const kinds = lines.map((l) => l.kind); - expect(kinds).toEqual(['context', 'context', 'delete', 'delete']); - }); - - it('suppresses trailing deletes when isIncomplete is true', () => { - const lines = computeDiffLines('A\nB\nC\nD', 'A\nB', 1, 1, true); - const kinds = lines.map((l) => l.kind); - expect(kinds).toEqual(['context', 'context']); - }); - - it('suppresses all deletes when everything would be deleted and incomplete', () => { - const lines = computeDiffLines('A\nB\nC', '', 1, 1, true); - expect(lines).toEqual([]); - }); - - it('keeps trailing adds when isIncomplete is true', () => { - const lines = computeDiffLines('A\nB\nC', 'A\nB\nX', 1, 1, true); - const kinds = lines.map((l) => l.kind); - expect(kinds).toEqual(['context', 'context', 'delete', 'add']); - }); - - it('keeps internal delete blocks that are not trailing', () => { - const lines = computeDiffLines('A\nB\nC\nD', 'A\nC', 1, 1, true); - const kinds = lines.map((l) => l.kind); - expect(kinds).toEqual(['context', 'delete', 'context']); - }); -}); - -describe('renderDiffLines', () => { - it('does not show removed count for suppressed trailing deletes', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', true, 1, 1); - const text = stripAnsi(output.join('\n')); - expect(text).toContain('test.ts'); - expect(text).not.toContain('-2'); - expect(text).not.toContain('C'); - expect(text).not.toContain('D'); - // When trailing deletes are suppressed, only context lines remain; - // renderDiffLines only emits changed lines, so the body is empty. - expect(text).not.toContain('A'); - expect(text).not.toContain('B'); - }); - - it('shows removed count for complete diffs', () => { - const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', false, 1, 1); - const text = stripAnsi(output.join('\n')); - expect(text).toContain('-2'); - expect(text).toContain('C'); - expect(text).toContain('D'); - }); -}); - -describe('renderDiffLinesClustered', () => { - it('renders header with file path and counts', () => { - const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts'); - const text = stripAnsi(out[0]!); - expect(text).toContain('+1'); - expect(text).toContain('-1'); - expect(text).toContain('foo.ts'); - }); - - it('returns header only when there are no changes', () => { - const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts'); - expect(out).toHaveLength(1); - expect(stripAnsi(out[0]!)).toContain('foo.ts'); - }); - - it('shows context lines around a single change cluster', () => { - // Five lines, change line 3 only — context is 1 each side. - const oldText = ['L1', 'L2', 'L3', 'L4', 'L5'].join('\n'); - const newText = ['L1', 'L2', 'L3X', 'L4', 'L5'].join('\n'); - const text = stripAnsi( - renderDiffLinesClustered(oldText, newText, 'f.ts', { contextLines: 1 }).join('\n'), - ); - expect(text).toContain('L2'); - expect(text).toContain('L3'); - expect(text).toContain('L3X'); - expect(text).toContain('L4'); - expect(text).not.toContain('L1'); - expect(text).not.toContain('L5'); - }); - - it('elides unchanged middle between two clusters with a separator', () => { - const oldLines: string[] = []; - for (let i = 1; i <= 30; i++) oldLines.push(`L${String(i)}`); - const newLines = oldLines.slice(); - newLines[1] = 'L2X'; // change near top - newLines[28] = 'L29X'; // change near bottom - const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { - contextLines: 2, - }).join('\n'), - ); - expect(text).toContain('L2X'); - expect(text).toContain('L29X'); - expect(text).toMatch(/… \d+ unchanged lines? …/); - // Middle untouched lines (e.g. L15) should not appear. - expect(text).not.toContain('L15'); - }); - - it('merges nearby change clusters when the gap is within context window', () => { - const oldLines: string[] = []; - for (let i = 1; i <= 10; i++) oldLines.push(`L${String(i)}`); - const newLines = oldLines.slice(); - newLines[2] = 'L3X'; - newLines[5] = 'L6X'; // gap of 2 lines between change indices 2 and 5 → merges with contextLines=2 (mergeGap=4) - const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { - contextLines: 2, - }).join('\n'); - const text = stripAnsi(out); - expect(text).not.toMatch(/unchanged lines? …/); - expect(text).toContain('L3X'); - expect(text).toContain('L6X'); - }); - - it('emits a partial body even when a single cluster exceeds maxLines', () => { - // Worst case from prod: 100 lines fully replaced inline → single huge - // cluster of ~200 diff entries. With maxLines=10 the renderer must - // still emit ~10 leading body rows, not just the truncation footer. - const oldLines: string[] = []; - const newLines: string[] = []; - for (let i = 1; i <= 100; i++) { - oldLines.push(`old${String(i)}`); - newLines.push(`new${String(i)}`); - } - const out = renderDiffLinesClustered( - oldLines.join('\n'), - newLines.join('\n'), - 'big.ts', - { - contextLines: 3, - maxLines: 10, - }, - ); - // header + 10 body rows + truncation footer - expect(out.length).toBe(12); - const text = stripAnsi(out.join('\n')); - expect(text).toContain('+100'); - expect(text).toContain('-100'); - expect(text).toMatch(/old\d+|new\d+/); - expect(text).toContain('ctrl+o to expand'); - }); - - it('truncates at cluster boundary and appends the ctrl+o footer when maxLines is set', () => { - const oldLines: string[] = []; - for (let i = 1; i <= 50; i++) oldLines.push(`L${String(i)}`); - const newLines = oldLines.slice(); - newLines[1] = 'L2X'; - newLines[20] = 'L21X'; - newLines[40] = 'L41X'; - const text = stripAnsi( - renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', { - contextLines: 2, - maxLines: 6, - }).join('\n'), - ); - expect(text).toContain('L2X'); - expect(text).toMatch(/more change/); - expect(text).toContain('ctrl+o to expand'); - expect(text).not.toContain('L41X'); +describe('renderDiffLinesClustered tail mode', () => { + it('keeps the tail when tail=true and exceeds maxLines', () => { + const out = renderDiffLinesClustered(lines(40), lines(40, 'changed'), 'a.ts', { + maxLines: 10, + tail: true, + }); + const joined = out.join('\n'); + expect(joined).toContain('earlier lines hidden'); + expect(joined).toContain('changed 40'); // tail change kept + expect(joined).not.toContain('changed 1'); // head change dropped + }); + + it('keeps the head when tail=false', () => { + const out = renderDiffLinesClustered(lines(40), lines(40, 'changed'), 'a.ts', { + maxLines: 10, + tail: false, + }); + const joined = out.join('\n'); + expect(joined).toContain('more changes hidden'); + expect(joined).toContain('line 1'); // head delete line kept + expect(joined).not.toContain('changed 40'); // tail add line dropped }); }); diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts index adf4d3b0b9..45dbcc2bc8 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -231,3 +231,30 @@ describe('AgentGroupComponent', () => { b.dispose(); }); }); + +describe('AgentGroupComponent height stability', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps a stable two-row body per agent after completion', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + startAgent(a, 'call_agent_1', 'explore'); + a.appendSubToolCall({ id: 'sub_call_agent_1:read', name: 'Read', args: { path: 'src/a.ts' } }); + group.attach('call_agent_1', a); + + const runningLines = group.render(120).length; + + a.onSubagentCompleted({ resultSummary: 'Done' }); + vi.advanceTimersByTime(250); + const doneLines = group.render(120).length; + + expect(doneLines).toBe(runningLines); + group.dispose(); + a.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/fixed-height-window.test.ts b/apps/kimi-code/test/tui/components/messages/fixed-height-window.test.ts new file mode 100644 index 0000000000..1384f1f631 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/fixed-height-window.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import { FixedHeightWindow } from '#/tui/components/messages/fixed-height-window'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('FixedHeightWindow', () => { + it('pads short content to the fixed height', () => { + const win = new FixedHeightWindow({ height: 4, lines: ['a', 'b'] }); + const out = win.render(80).map(strip); + expect(out).toHaveLength(4); + expect(out[0]).toContain('a'); + expect(out[1]).toContain('b'); + }); + + it('keeps the tail when content exceeds height and tail=true', () => { + const win = new FixedHeightWindow({ + height: 3, + tail: true, + lines: ['l1', 'l2', 'l3', 'l4', 'l5'], + }); + const out = win.render(80).map(strip); + expect(out).toHaveLength(3); + expect(out.join('\n')).toContain('l5'); + expect(out.join('\n')).not.toContain('l1'); + }); + + it('keeps the head when tail=false', () => { + const win = new FixedHeightWindow({ + height: 3, + tail: false, + lines: ['l1', 'l2', 'l3', 'l4', 'l5'], + }); + const out = win.render(80).map(strip); + expect(out).toHaveLength(3); + expect(out.join('\n')).toContain('l1'); + expect(out.join('\n')).not.toContain('l5'); + }); + + it('returns identical line count across different content lengths', () => { + const win = new FixedHeightWindow({ height: 5, lines: ['x'] }); + const short = win.render(80).length; + win.setLines(['1', '2', '3', '4', '5', '6', '7', '8']); + const long = win.render(80).length; + expect(short).toBe(5); + expect(long).toBe(5); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts index 510da06bd6..d2b13380fa 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -67,4 +67,13 @@ describe('ShellRunComponent hardening', () => { c.render(100); }).not.toThrow(); }); + + it('keeps a stable height between running and finished', () => { + const c = create(); + c.append('line1\nline2\nline3'); + const runningLines = c.render(100).length; + c.finish('out1\nout2\nout3\nout4\nout5\nout6\nout7', '', false); + const finishedLines = c.render(100).length; + expect(finishedLines).toBe(runningLines); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index e615d7f5cd..886b1a0753 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -89,4 +89,21 @@ describe('ThinkingComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(37); } }); + + it('keeps the same line count in live and finalized modes for long content', () => { + const live = new ThinkingComponent(longThinking, true, 'live'); + const liveLines = live.render(80).length; + live.finalize(); + const finalLines = live.render(80).length; + expect(finalLines).toBe(liveLines); + expect(finalLines).toBe(4); + }); + + it('does not pad short finalized thinking with blank lines', () => { + const c = new ThinkingComponent('one line', true, 'live'); + c.finalize(); + const lines = c.render(80); + expect(lines.length).toBeLessThanOrEqual(3); + expect(lines.at(-1)?.trim()).not.toBe(''); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 2b796829ed..1333b80758 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1525,7 +1525,9 @@ describe('ToolCallComponent', () => { // Line numbers should reflect actual file positions. expect(out).toContain(' 21'); expect(out).toContain(' 30'); - expect(out).not.toContain('ctrl+o to expand'); + // Streaming preview now appends the same hint as the finalized preview + // so the row count stays stable across the streaming -> finalized switch. + expect(out).toContain('ctrl+o to expand'); }); it('switches a streaming tool call to Truncated when the step ended with max_tokens', () => { @@ -1829,4 +1831,41 @@ describe('ToolCallComponent', () => { stderr.restore(); } }); + + it('caps progress lines at a fixed window while running', () => { + const tc = new ToolCallComponent( + { id: 'bash1', name: 'Bash', args: { command: 'echo hi' } }, + undefined, + stubTui(40), + ); + for (let i = 0; i < 20; i++) { + tc.appendProgress(`progress line ${i}`); + } + const out = tc.render(120); + const progressLines = out.filter((line) => line.includes('progress line')); + expect(progressLines.length).toBeLessThanOrEqual(6); + expect(progressLines.at(-1) ?? '').toContain('progress line 19'); + }); + + it('caps Edit preview at args finalize, before result lands', () => { + const many = (n: number): string => + Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\n'); + const tc = new ToolCallComponent( + { + id: 'e1', + name: 'Edit', + args: { + file_path: 'a.ts', + old_string: many(40), + new_string: many(40), + }, + }, + undefined, + stubTui(40), + ); + const argsLines = tc.render(120).length; // args finalized, result not yet + tc.setResult({ tool_call_id: 'e1', output: 'ok', is_error: false }); + const resultLines = tc.render(120).length; + expect(Math.abs(resultLines - argsLines)).toBeLessThanOrEqual(1); + }); }); diff --git a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts index ee6dcbb1c6..090ac4c00e 100644 --- a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts @@ -381,3 +381,33 @@ describe('formatHiddenCounts', () => { expect(formatHiddenCounts({ done: 0, in_progress: 0, pending: 0 })).toBe(''); }); }); + +describe('TodoPanelComponent placeholder', () => { + it('holds placeholder lines at the last height after clearing', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([ + { title: 'a', status: 'done' }, + { title: 'b', status: 'in_progress' }, + ]); + const before = panel.render(80).length; // border + header + 2 rows + panel.setTodos([]); // cleared + const after = panel.render(80); + expect(after).toHaveLength(before); // placeholder at last height + expect(after.every((line) => line.trim() === '')).toBe(true); + }); + + it('returns no lines when empty before any todos were shown', () => { + const panel = new TodoPanelComponent(); + expect(panel.render(80)).toEqual([]); + }); + + it('clearPlaceholder removes the placeholder lines', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([{ title: 'a', status: 'done' }]); + panel.render(80); + panel.setTodos([]); + expect(panel.render(80).length).toBeGreaterThan(0); + panel.clearPlaceholder(); + expect(panel.render(80)).toEqual([]); + }); +}); 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 ae6a4b8a50..afc1c4e782 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 @@ -1318,6 +1318,8 @@ command = "vim" expect(transcript).toContain('Review changed files'); driver.state.appState.streamingPhase = 'idle'; + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); driver.handleUserInput('/undo'); await confirmUndoSelection(driver); @@ -1329,6 +1331,7 @@ command = "vim" expect(transcript).not.toContain('launch swarm'); expect(transcript).not.toContain('Agent Swarm'); expect(transcript).not.toContain('Review changed files'); + expect(requestRender.mock.calls.some((call) => call[0] === true)).toBe(true); }); it('removes approval notices from undone turns', async () => { @@ -4763,3 +4766,23 @@ describe('/effort support_efforts override', () => { expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking max.'); }); }); + +describe('restoreEditor', () => { + it('forces a full render to clear dialog residue', async () => { + const { driver } = await makeDriver(); + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); + driver.restoreEditor(); + expect(requestRender).toHaveBeenCalledWith(true); + }); +}); + +describe('toggleTodoPanelExpansion', () => { + it('forces a full render', async () => { + const { driver } = await makeDriver(); + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); + (driver as any).toggleTodoPanelExpansion(); + expect(requestRender).toHaveBeenCalledWith(true); + }); +}); diff --git a/plan/2026-07-04-dynamic-ui-height-fix.md b/plan/2026-07-04-dynamic-ui-height-fix.md new file mode 100644 index 0000000000..b4dec2a039 --- /dev/null +++ b/plan/2026-07-04-dynamic-ui-height-fix.md @@ -0,0 +1,1243 @@ +# 动态高度 UI 组件抖动修复 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复 `apps/kimi-code` 中 10 个会「先高 → 高度变化 → 最后变矮」的动态 UI 组件的视觉抖动,让它们在生命周期内对外返回固定/受控行数,不推动外部布局。 + +**Architecture:** 不改 pi-tui 底层,只在 `apps/kimi-code` 客户端改。三条策略组合:**等高化**(组件在生命周期内返回固定行数)、**占位/摘要替代**(消失场景用固定占位而非彻底清空)、**关闭时全量重绘**(dialog/autocomplete/BTW/AgentSwarm 关闭时 `requestRender(true)` 清脏内容)。复用现成工具 `PrefixedWrappedLine(tailLines, minLines)`、`Spacer`、`BtwPanelComponent.fitBodyLines()`。 + +**Tech Stack:** TypeScript、vitest(`describe`/`it`/`expect`/`vi`)、pi-tui `Component` 接口(`render(width): string[]`)、`@moonshot-ai/pi-tui` 的 `Editor`/`SelectList`/`Container`。 + +--- + +## 0. 背景与根因 + +pi-tui 的 `Container.render()` 顺序拼接子组件行(`packages/pi-tui/src/tui.ts:263`)。任一组件返回的行数变化,后续所有组件的逻辑行号必然移动,差量渲染把它们重绘到新位置——这就是抖动。`requestRender(true)` / resize 还会触发破坏性清屏重排。 + +要消除抖动,在只改客户端的前提下:**让动态组件对外返回固定行数**,把高度变化限制在组件内部(用 tail 窗口 / padding / 占位),不推动外部布局。 + +## 1. 修复范围(10 个场景) + +| # | 场景 | 策略 | 落点 | +|---|---|---|---| +| 1a | 斜杠命令打开的 dialog/selector 关闭 | 全量重绘 | `kimi-tui.ts:2601` `restoreEditor` | +| 1b | 斜杠/`@`/路径 autocomplete 下拉关闭 | 全量重绘 | `custom-editor.ts:260` `render()` | +| 2 | `AgentGroupComponent` 多 subagent 组 | 等高化 | `agent-group.ts:179-224` | +| 3 | `ThinkingComponent` 思考面板 | 等高化 | `thinking.ts:90-144` | +| 4 | `ToolCallComponent` progress/liveOutput/result | 等高化(固定 6 行输出窗口) | `tool-call.ts:1518-1554`、`buildContent` | +| 5 | `ShellRunComponent` 用户 `!` 命令 | 等高化 | `shell-run.ts:29` | +| 6 | AgentSwarm 面板摘除 | 全量重绘 | `subagent-event-handler.ts:546-559` | +| 7 | Todo 清空消失 | 临时占位 | `todo-panel.ts:114` + `streaming-ui.ts` | +| 8 | Todo Ctrl+T 展开/折叠 | 全量重绘 | `kimi-tui.ts:2367-2370` | +| 9 | BTW Esc 关闭 | 全量重绘 | `btw-panel.ts:141-144` | +| 10 | `ToolCallComponent` Write/Edit preview | 等高化(始终 capped) | `tool-call.ts:1904-1980` | + +## 2. 文件结构 + +**新建:** +- `apps/kimi-code/src/tui/components/messages/fixed-height-window.ts` —— 固定高度窗口组件,封装 `PrefixedWrappedLine`,对外接受 `lines` + `height` + `tail`,内部 tail/slice/padding,输出固定行数。 + +**修改:** +- `apps/kimi-code/src/tui/kimi-tui.ts` —— `restoreEditor` 改 `requestRender(true)`(Task 2);`toggleTodoPanelExpansion` 改 `requestRender(true)`(Task 10)。 +- `apps/kimi-code/src/tui/components/editor/custom-editor.ts` —— `render()` 检测 autocomplete 关闭,触发 `requestRender(true)`(Task 3)。 +- `apps/kimi-code/src/tui/components/messages/agent-group.ts` —— done agent 保留第二行摘要,detach hint 固定 1 行(Task 4)。 +- `apps/kimi-code/src/tui/components/messages/thinking.ts` —— live/finalized 共用固定骨架(Task 5)。 +- `apps/kimi-code/src/tui/components/messages/tool-call.ts` —— progress/liveOutput/result 合并固定 6 行窗口;Write/Edit preview 始终 capped;Edit preview 用 tail 模式(Task 6、11)。 +- `apps/kimi-code/src/tui/components/media/diff-preview.ts` —— `renderDiffLinesClustered` 新增 `tail` 选项(Task 11)。 +- `apps/kimi-code/test/tui/components/media/diff-preview.test.ts`(新)—— `renderDiffLinesClustered` tail 模式测试。 +- `apps/kimi-code/src/tui/components/messages/shell-run.ts` —— 运行中/完成后同一固定窗口(Task 7)。 +- `apps/kimi-code/src/tui/controllers/subagent-event-handler.ts` —— `removeAgentSwarmProgress` 后 `requestRender(true)`(Task 8)。 +- `apps/kimi-code/src/tui/components/chrome/todo-panel.ts` —— 清空时临时占位到上次高度;`clearPlaceholder()`(Task 9)。 +- `apps/kimi-code/src/tui/controllers/streaming-ui.ts` —— AI 新一轮输出时调用 `todoPanel.clearPlaceholder()`(Task 9)。 +- `apps/kimi-code/src/tui/controllers/btw-panel.ts` —— `close()` 后 `requestRender(true)`(Task 11)。 + +**测试:** +- `apps/kimi-code/test/tui/components/messages/fixed-height-window.test.ts`(新) +- `apps/kimi-code/test/tui/components/messages/agent-group.test.ts`(改) +- `apps/kimi-code/test/tui/components/messages/thinking.test.ts`(改) +- `apps/kimi-code/test/tui/components/messages/tool-call.test.ts`(改) +- `apps/kimi-code/test/tui/components/messages/shell-run.test.ts`(改) +- `apps/kimi-code/test/tui/components/panels/todo-panel.test.ts`(改) +- `apps/kimi-code/test/tui/components/editor/custom-editor.test.ts`(改) + +## 3. 测试约定 + +- 框架:vitest。`import { describe, it, expect, vi } from 'vitest'`。 +- 渲染断言:`const out = strip(component.render(80).join('\n'))`,`expect(out).toContain(...)` / `not.toContain(...)`。 +- `strip(text)`:去掉 ANSI 转义码(各测试文件已有)。 +- mock TUI:`{ terminal: { rows: 40 }, requestRender: vi.fn() } as unknown as TUI`。 +- 动画/定时器:`vi.useFakeTimers()` + `vi.advanceTimersByTime(ms)`,结束 `vi.useRealTimers()`。 +- 等高断言:固定高度 H 时,断言 `component.render(80).length === H`(在多个状态下)。 +- 运行测试:`pnpm --filter @moonshot-ai/kimi-code test `(执行时核对确切命令,见根 `package.json`)。 + +--- + +## Task 1: 共享基础设施 `FixedHeightWindow` + +**Files:** +- Create: `apps/kimi-code/src/tui/components/messages/fixed-height-window.ts` +- Test: `apps/kimi-code/test/tui/components/messages/fixed-height-window.test.ts` + +封装一个固定高度窗口组件,供 Task 4/5/6/7 复用。它接受任意文本行,输出固定 `height` 行:超出按 `tail` 取尾或取头,不足 padding 空行,并在截断时附加 `… N more` 提示行(提示计入高度内)。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/kimi-code/test/tui/components/messages/fixed-height-window.test.ts +import { describe, it, expect } from 'vitest'; +import { FixedHeightWindow } from '#/tui/components/messages/fixed-height-window'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('FixedHeightWindow', () => { + it('pads short content to the fixed height', () => { + const win = new FixedHeightWindow({ height: 4, lines: ['a', 'b'] }); + const out = win.render(80).map(strip); + expect(out).toHaveLength(4); + expect(out[0]).toContain('a'); + expect(out[1]).toContain('b'); + }); + + it('keeps the tail when content exceeds height and tail=true', () => { + const win = new FixedHeightWindow({ + height: 3, + tail: true, + lines: ['l1', 'l2', 'l3', 'l4', 'l5'], + }); + const out = win.render(80).map(strip); + expect(out).toHaveLength(3); + expect(out.join('\n')).toContain('l5'); + expect(out.join('\n')).not.toContain('l1'); + }); + + it('keeps the head when tail=false', () => { + const win = new FixedHeightWindow({ + height: 3, + tail: false, + lines: ['l1', 'l2', 'l3', 'l4', 'l5'], + }); + const out = win.render(80).map(strip); + expect(out).toHaveLength(3); + expect(out.join('\n')).toContain('l1'); + expect(out.join('\n')).not.toContain('l5'); + }); + + it('returns identical line count across different content lengths', () => { + const win = new FixedHeightWindow({ height: 5, lines: ['x'] }); + const short = win.render(80).length; + win.setLines(['1', '2', '3', '4', '5', '6', '7', '8']); + const long = win.render(80).length; + expect(short).toBe(5); + expect(long).toBe(5); + }); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/fixed-height-window.test.ts` +Expected: FAIL,模块不存在。 + +- [ ] **Step 3: 实现 `FixedHeightWindow`** + +```ts +// apps/kimi-code/src/tui/components/messages/fixed-height-window.ts +import type { Component } from '@moonshot-ai/pi-tui'; + +export interface FixedHeightWindowOptions { + height: number; + lines?: string[]; + tail?: boolean; // default true +} + +export class FixedHeightWindow implements Component { + private lines: string[]; + private readonly height: number; + private readonly tail: boolean; + + constructor(opts: FixedHeightWindowOptions) { + this.height = Math.max(0, opts.height); + this.tail = opts.tail ?? true; + this.lines = opts.lines ?? []; + } + + setLines(lines: string[]): void { + this.lines = lines; + } + + invalidate(): void {} + + render(_width: number): string[] { + if (this.height === 0) return []; + const src = this.lines; + let shown: string[]; + if (src.length > this.height) { + shown = this.tail ? src.slice(src.length - this.height) : src.slice(0, this.height); + } else { + shown = [...src]; + } + while (shown.length < this.height) shown.push(''); + return shown; + } +} +``` + +> 注:首版不附加 `… N more` 提示行(保持窗口纯粹等高)。截断提示由各调用方按需要自行在外层加(如 `ThinkingComponent` 的 `ctrl+o to expand`)。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/fixed-height-window.test.ts` +Expected: PASS(4 tests)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/components/messages/fixed-height-window.ts apps/kimi-code/test/tui/components/messages/fixed-height-window.test.ts +git commit -m "feat(tui): add FixedHeightWindow component for stable-height rendering" +``` + +--- + +## Task 2: `restoreEditor` 关闭 dialog 时全量重绘(#1a) + +**Files:** +- Modify: `apps/kimi-code/src/tui/kimi-tui.ts:2597-2602` +- Test: `apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts`(追加用例;执行时核对 KimiTUI 实例化方式,必要时新建 `restore-editor.test.ts`) + +`restoreEditor` 是所有 dialog/selector 关闭后的统一出口。当前用普通 `requestRender()`,差量渲染会在 scrollback 留下脏内容、footer 位置残留。改为 `requestRender(true)`,与 `Ctrl+O`(`kimi-tui.ts:2335`)一致。 + +- [ ] **Step 1: 写失败测试** + +```ts +// 在 kimi-tui-message-flow.test.ts 内追加(或新建 restore-editor.test.ts) +it('restoreEditor forces a full render to clear dialog residue', () => { + const tui = createTestTui(); // 用文件内已有的 helper;若无,构造一个最小 KimiTUI + const requestRender = vi.spyOn(tui.state.ui, 'requestRender'); + tui.restoreEditor(); + expect(requestRender).toHaveBeenCalledWith(true); +}); +``` + +> 若 `createTestTui`/`KimiTUI` 实例化成本过高,退而测试 `restoreEditor` 的契约:spy `state.ui.requestRender`,调用 `restoreEditor()`,断言以 `true` 调用。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/kimi-tui-message-flow.test.ts` +Expected: FAIL,`requestRender` 被调用但参数不是 `true`。 + +- [ ] **Step 3: 修改 `restoreEditor`** + +```ts +// apps/kimi-code/src/tui/kimi-tui.ts:2597 +restoreEditor(): void { + this.state.editorContainer.clear(); + this.state.editorContainer.addChild(this.state.editor); + this.state.ui.setFocus(this.state.editor); + // Closing a dialog/selector shrinks the editor area; differential rendering + // leaves stale bytes in scrollback and a misplaced footer. Force a full + // render, matching the Ctrl+O expansion toggle. + this.state.ui.requestRender(true); +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/kimi-tui-message-flow.test.ts` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/kimi-tui.ts apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +git commit -m "fix(tui): force full render when restoring editor after dialog close" +``` + +--- + +## Task 3: `CustomEditor` autocomplete 下拉关闭时全量重绘(#1b) + +**Files:** +- Modify: `apps/kimi-code/src/tui/components/editor/custom-editor.ts:121、260` +- Test: `apps/kimi-code/test/tui/components/editor/custom-editor.test.ts`(追加用例) + +斜杠/`@`/路径 autocomplete 下拉是 pi-tui editor 内部状态,关闭走 pi-tui 内部 `clearAutocompleteUi`,不经过 `restoreEditor`。在 `CustomEditor.render()`(已 override)里检测「从有到无」的转换,触发一次 `requestRender(true)`。pi-tui 暴露 `isShowingAutocomplete()`(`custom-editor.ts:255` 注释已使用)。 + +- [ ] **Step 1: 写失败测试** + +```ts +// 在 custom-editor.test.ts 内追加 +it('forces a full render when the autocomplete dropdown closes', () => { + const requestRender = vi.fn(); + const editor = createEditor({ requestRender }); // 用文件内已有 helper + // 打开下拉 + editor.showAutocompleteForTest([{ value: '/model', label: '/model' }]); + editor.render(80); + requestRender.mockClear(); + // 关闭下拉 + editor.hideAutocompleteForTest(); + editor.render(80); + expect(requestRender).toHaveBeenCalledWith(true); +}); +``` + +> `showAutocompleteForTest`/`hideAutocompleteForTest` 是测试辅助,调用 pi-tui 的 `tryTriggerAutocomplete` / `cancelAutocomplete`。执行时按 `custom-editor.test.ts` 现有 helper 对齐;若没有暴露,通过 `(editor as any)` 访问私有方法触发。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/editor/custom-editor.test.ts` +Expected: FAIL,关闭下拉时 `requestRender(true)` 未被调用。 + +- [ ] **Step 3: 修改 `CustomEditor`** + +```ts +// apps/kimi-code/src/tui/components/editor/custom-editor.ts + +export class CustomEditor extends Editor { + private wasShowingAutocomplete = false; + // ... 已有字段 + + override render(width: number): string[] { + const lines = super.render(width); + const showing = this.isShowingAutocomplete(); + if (this.wasShowingAutocomplete && !showing) { + // Autocomplete dropdown just closed: the editor returned fewer lines, + // which would pull the footer up and leave stale rows in scrollback. + this.ui?.requestRender(true); + } + this.wasShowingAutocomplete = showing; + // ... 保留原有 render 后续逻辑(paste burst 等) + return lines; + } +} +``` + +> `isShowingAutocomplete()` 是 pi-tui `Editor` 的 public 方法(`custom-editor.ts:255` 注释确认)。`this.ui` 来自 pi-tui `Editor` 基类。`requestRender(true)` 是 `process.nextTick` 异步,不会同步递归 render;下一帧 `wasShowingAutocomplete === showing === false`,不再触发,最多多一次 render。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/editor/custom-editor.test.ts` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/components/editor/custom-editor.ts apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +git commit -m "fix(tui): force full render when autocomplete dropdown closes" +``` + +--- + +## Task 4: `AgentGroupComponent` 等高化(#2) + +**Files:** +- Modify: `apps/kimi-code/src/tui/components/messages/agent-group.ts:179-224` +- Test: `apps/kimi-code/test/tui/components/messages/agent-group.test.ts`(追加/调整用例) + +运行中每个 agent 2 行(分支行 + activity 行 `Using {name} ({keyArg})`),done 后省略 activity 行 → 2N→N 塌缩。改为:done 的 agent 也保留第二行,显示 `latestActivity`(完成态落到「latest finished sub-tool」,即 `Used {name} ({keyArg})`,见 `tool-call.ts:84-87`,保留「这个 agent 最后做了什么工具调用」),无活动时回退到 phase 文案;detach hint 固定占 1 行(无 running 时留空行)。让运行中和完成后等高(每 agent 恒 2 行)。 + +- [ ] **Step 1: 写失败测试** + +```ts +// 在 agent-group.test.ts 内追加 +it('keeps a stable two-row body per agent after completion', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + startAgent(a, 'call_agent_1', 'explore'); + group.attach('call_agent_1', a); + + const runningLines = group.render(120).length; + + // 完成 agent(执行时按 ToolCallComponent 实际 API 触发 done) + a.onSubagentCompleted?.({ agentId: 'sub_call_agent_1', /* ... */ } as any); + const doneLines = group.render(120).length; + + expect(doneLines).toBe(runningLines); // 等高 + const out = renderText(group); + expect(out).toContain('explore'); // 分支行仍在 + group.dispose(); + a.dispose(); +}); +``` + +> `onSubagentCompleted` 的精确签名执行时核对 `ToolCallComponent`。核心是:同一 agent 在 running 与 done 两种状态下,`group.render(width).length` 必须相等。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/agent-group.test.ts` +Expected: FAIL,done 后行数小于 running。 + +- [ ] **Step 3: 修改 `appendLines` 与 detach hint** + +```ts +// apps/kimi-code/src/tui/components/messages/agent-group.ts + +private appendLines(snap: SubagentSnapshot, isLast: boolean): void { + const branch1 = isLast ? '└─' : '├─'; + const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; + this.bodyContainer.addChild(new Text(line1, 0, 0)); // 第一行:永远有 + + const branch2 = isLast ? ' ' : '│ '; + if (snap.phase === 'failed') { + this.bodyContainer.addChild(new Text(` ${branch2} ${errStr}`, 0, 0)); + return; + } + if (snap.phase === 'done' || snap.phase === 'backgrounded') { + // 保留第二行:latestActivity 在完成态已经是 "Used {name} ({keyArg})" + // (finished sub-tool 优先,见 tool-call.ts:84-87),正好展示这个 agent + // 最后做了什么工具调用。没有任何活动时回退到 phase 文案。与运行中等高。 + const activity = + snap.latestActivity ?? (snap.phase === 'done' ? 'Completed' : 'Backgrounded'); + this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); + return; + } + // running / queued / spawning + const activity = snap.latestActivity ?? fallbackActivityForPhase(snap.phase); + this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); +} + +// detach hint 固定占 1 行 +private renderDetachHint(snapshots: SubagentSnapshot[]): void { + if (this.shouldShowDetachHint(snapshots)) { + this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } else { + this.bodyContainer.addChild(new Text('', 0, 0)); // 占位空行,保持等高 + } +} +``` + +> `snap.elapsedLabel` 字段名执行时核对 `getSubagentSnapshot()` 的返回结构。若不存在,用 `latestActivity` 或固定文案(如 `'Done'`)。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/agent-group.test.ts` +Expected: PASS(含原有用例)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/components/messages/agent-group.ts apps/kimi-code/test/tui/components/messages/agent-group.test.ts +git commit -m "fix(tui): keep AgentGroup body height stable after agents complete" +``` + +--- + +## Task 5: `ThinkingComponent` 等高化(#3) + +**Files:** +- Modify: `apps/kimi-code/src/tui/components/messages/thinking.ts:90-144` +- Test: `apps/kimi-code/test/tui/components/messages/thinking.test.ts`(追加/调整用例) + +live(spinner + 尾窗 2 行 = 3~4 行)与 finalized(空白 + 前 2 行 + 提示 = 4 行)布局切换导致抖动。改为共用同一固定骨架:固定 **4 行** = `1 行空白 + 1 行 header + 2 行内容窗口`。内容窗口用 `FixedHeightWindow(height=2)`,live 取尾、finalized 取头。finalized 截断提示并入第 2 行内容。 + +- [ ] **Step 1: 写失败测试** + +```ts +// 在 thinking.test.ts 内追加 +it('keeps the same line count in live and finalized modes', () => { + const live = new ThinkingComponent(longThinking, true, 'live'); + const liveLines = live.render(80).length; + + live.finalize(); + const finalLines = live.render(80).length; + + expect(finalLines).toBe(liveLines); // 等高 + expect(finalLines).toBe(4); // 固定 4 行 +}); + +it('keeps a stable height for short thinking content', () => { + const c = new ThinkingComponent('one line', true, 'live'); + const liveLines = c.render(80).length; + c.finalize(); + expect(c.render(80).length).toBe(liveLines); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/thinking.test.ts` +Expected: FAIL,live 与 finalized 行数不一致。 + +- [ ] **Step 3: 重构 `render`** + +```ts +// apps/kimi-code/src/tui/components/messages/thinking.ts +import { FixedHeightWindow } from './fixed-height-window'; + +private static readonly WINDOW_LINES = 2; // 内容窗口固定 2 行 + +render(width: number): string[] { + const contentLines = this.content.split('\n'); + + if (this.mode === 'live') { + const frame = BRAILLE_SPINNER_FRAMES[this.spinnerFrame]!; + const header = `${currentTheme.fg('textDim', frame)} ${currentTheme.fg('textDim', 'thinking...')}`; + const window = new FixedHeightWindow({ + height: ThinkingComponent.WINDOW_LINES, + tail: true, + lines: contentLines, + }); + return [ + '', + header, + ...window.render(width).map((line) => MESSAGE_INDENT + line), + ]; + } + + // finalized + const header = this.buildFinalizedHeader(); // 沿用现有 header 文案 + const window = new FixedHeightWindow({ + height: ThinkingComponent.WINDOW_LINES, + tail: false, + lines: contentLines, + }); + const windowLines = window.render(width); + if (contentLines.length > ThinkingComponent.WINDOW_LINES) { + const remaining = contentLines.length - ThinkingComponent.WINDOW_LINES; + windowLines[windowLines.length - 1] = currentTheme.dim( + `${MESSAGE_INDENT}... (${String(remaining)} more lines, ctrl+o to expand)`, + ); + } + return ['', header, ...windowLines.map((line) => MESSAGE_INDENT + line)]; +} +``` + +> `buildFinalizedHeader()` 提取现有 finalized header 文案逻辑。`MESSAGE_INDENT`、`currentTheme`、`BRAILLE_SPINNER_FRAMES` 沿用现有 import。执行时核对 `thinking.ts` 现有变量名。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/thinking.test.ts` +Expected: PASS(原有用例需同步调整:原来断言 `not.toContain('line3')` 仍成立;新增等高断言)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/components/messages/thinking.ts apps/kimi-code/test/tui/components/messages/thinking.test.ts +git commit -m "fix(tui): keep ThinkingComponent height stable across live and finalized modes" +``` + +--- + +## Task 6: `ToolCallComponent` 输出窗口等高化(#4) + +**Files:** +- Modify: `apps/kimi-code/src/tui/components/messages/tool-call.ts:621-645、1518-1554、2093-2160` +- Test: `apps/kimi-code/test/tui/components/messages/tool-call.test.ts`(追加用例) + +progress 行(≤24)+ liveOutput(≤3)在 `setResult()` 时清空,从最多 27 行塌到 content 行数。改为:把 `progress + liveOutput + result content` 合并成**一个固定 6 行的输出窗口**。运行中显示 progress + liveOutput 的尾部 6 行;完成后显示 result content 的尾部 6 行;不足 padding,全程等高。 + +- [ ] **Step 1: 写失败测试** + +```ts +// 在 tool-call.test.ts 内追加 +it('keeps a stable output window height between running and completed', () => { + const ui = stubTui(); + const tc = new ToolCallComponent( + { id: 'bash1', name: 'Bash', args: { command: 'echo hi' } }, + undefined, + ui, + ); + // 运行中:塞入 progress + liveOutput + tc.appendProgress('p1\np2\np3\np4\np5'); + tc.setLiveOutput('o1\no2\no3'); + const runningLines = tc.render(120).length; + + // 完成后:result 到达 + tc.setResult({ output: 'r1\nr2', is_error: false }); + const doneLines = tc.render(120).length; + + expect(doneLines).toBe(runningLines); // 等高 +}); +``` + +> `appendProgress`/`setLiveOutput`/`setResult` 的精确 API 执行时核对 `tool-call.ts`。核心是:running 与 done 两态 `render().length` 相等。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/tool-call.test.ts` +Expected: FAIL,setResult 后行数塌缩。 + +- [ ] **Step 3: 重构 progress/liveOutput/content 为固定窗口** + +在 `ToolCallComponent` 内新增私有方法 `buildOutputWindow()`,替代原来的 `buildProgressBlock()` + `buildLiveOutputBlock()` + `buildContent()` 三个独立 child: + +```ts +// apps/kimi-code/src/tui/components/messages/tool-call.ts +import { FixedHeightWindow } from './fixed-height-window'; + +private static readonly OUTPUT_WINDOW_LINES = 6; + +private buildOutputWindow(): void { + const lines = this.collectOutputLines(); + const window = new FixedHeightWindow({ + height: ToolCallComponent.OUTPUT_WINDOW_LINES, + tail: true, + lines, + }); + // 用 window 的输出替换原来的 progress/liveOutput/content 三个 child + const rendered = window.render(this.lastRenderWidth); + for (const line of rendered) { + this.addChild(new Text(line, 0, 0)); + } +} + +private collectOutputLines(): string[] { + if (this.result !== undefined) { + // 完成后:显示 result content(按工具 renderer 取文本) + return this.renderResultLines(); + } + // 运行中:progress + liveOutput + return [...this.progressLines, ...this.liveOutputLines]; +} +``` + +构造函数里把原来的: +```ts +this.buildProgressBlock(); +this.buildLiveOutputBlock(); +this.buildContent(); +``` +替换为: +```ts +this.buildOutputWindow(); +``` + +`setResult()` 里清空 `progressLines`/`liveOutput` 后调用 `rebuildBody()`(已有),让窗口内容从 progress 切换到 result,但行数保持 6。 + +> 这是本计划最复杂的重构。执行时需先完整阅读 `tool-call.ts` 的 `buildProgressBlock`/`buildLiveOutputBlock`/`buildContent`/`rebuildBody`,确认子 child 的插入位置(`callPreviewEndIndex` 之后、`buildSubagentBlock` 之前)。`renderResultLines()` 复用现有 `pickResultRenderer()` 的输出(取其文本行)。若 content renderer 高度本身不固定(如 diff),统一用 `FixedHeightWindow` 包一层。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/tool-call.test.ts` +Expected: PASS(原有用例若有行数断言需同步调整)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/components/messages/tool-call.ts apps/kimi-code/test/tui/components/messages/tool-call.test.ts +git commit -m "fix(tui): stabilize ToolCall output window height across running and completed states" +``` + +--- + +## Task 7: `ShellRunComponent` 等高化(#5) + +**Files:** +- Modify: `apps/kimi-code/src/tui/components/messages/shell-run.ts:29` +- Test: `apps/kimi-code/test/tui/components/messages/shell-run.test.ts`(追加用例) + +运行中固定 7 行(5 行 tail + timing + hint),完成后切到完整输出(高度突变)。改为:运行中和完成后用**同一固定窗口**(tail N 行 + 状态行 + hint),完成后不显示完整输出,显示 tail + `completed` + `ctrl+o to expand`,与 ToolCall 对齐。 + +- [ ] **Step 1: 写失败测试** + +```ts +// 在 shell-run.test.ts 内追加 +it('keeps a stable height between running and completed', () => { + const c = new ShellRunComponent({ command: 'ls', /* ... */ } as any); + c.start?.(); + const runningLines = c.render(80).length; + c.finish?.({ stdout: 'a\nb\nc\nd\ne\nf\ng', stderr: '', isError: false }); + const doneLines = c.render(80).length; + expect(doneLines).toBe(runningLines); +}); +``` + +> `start`/`finish` 的精确 API 执行时核对 `shell-run.ts`。核心是 running 与 done 两态 `render().length` 相等。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/shell-run.test.ts` +Expected: FAIL,完成后行数变化。 + +- [ ] **Step 3: 重构 `renderText`** + +```ts +// apps/kimi-code/src/tui/components/messages/shell-run.ts +import { FixedHeightWindow } from './fixed-height-window'; + +private static readonly BODY_LINES = 5; + +private renderText(): string { + const source = this.running + ? this.combined + : formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError); + const allLines = sanitizeShellOutput(source).trimEnd().split('\n'); + + const window = new FixedHeightWindow({ + height: ShellRunComponent.BODY_LINES, + tail: true, + lines: allLines, + }); + const body = window + .render(this.lastWidth) + .map((line) => ` ${dim(line)}`) + .join('\n'); + + const hidden = Math.max(0, allLines.length - ShellRunComponent.BODY_LINES); + const status = this.running + ? `(${this.elapsed()}s)` + : `completed${hidden > 0 ? ` · +${String(hidden)} lines` : ''} · ctrl+o to expand`; + const hint = this.running ? '(ctrl+b to run in background)' : ''; + + return [body, ` ${dim(status)}`, hint ? ` ${dim(hint)}` : ''].filter((l) => l !== undefined).join('\n'); +} +``` + +> 完成后用 `FixedHeightWindow` tail 5 行替代完整输出。`formatBashOutputForDisplay`/`sanitizeShellOutput`/`dim` 沿用现有 import。执行时核对字段名(`combined`/`finalStdout`/`elapsed`)。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/shell-run.test.ts` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/components/messages/shell-run.ts apps/kimi-code/test/tui/components/messages/shell-run.test.ts +git commit -m "fix(tui): keep ShellRun output window height stable after completion" +``` + +--- + +## Task 8: AgentSwarm 面板摘除时全量重绘(#6) + +**Files:** +- Modify: `apps/kimi-code/src/tui/controllers/subagent-event-handler.ts:546-559` +- Test: `apps/kimi-code/test/tui/controllers/`(执行时确认是否有现成测试文件,无则新建 `subagent-event-handler.test.ts`) + +`removeAgentSwarmProgress` 把 `AgentSwarmProgressComponent` 从 transcript `splice` 掉,整块消失,后续内容上移。改为摘除后 `requestRender(true)`,清屏重排,避免差量渲染在 scrollback 留下脏内容。 + +- [ ] **Step 1: 写失败测试** + +```ts +// subagent-event-handler.test.ts +it('forces a full render when removing the AgentSwarm progress panel', () => { + const { handler, ui, transcriptContainer } = createHandler(); // 按现有 helper 构造 + const requestRender = vi.spyOn(ui, 'requestRender'); + // 先挂载一个 swarm 面板 + handler.ensureAgentSwarmProgress('tc1', { items: [...] } as any); + requestRender.mockClear(); + // 摘除 + handler.removeAgentSwarmProgress('tc1'); + expect(requestRender).toHaveBeenCalledWith(true); +}); +``` + +> `createHandler`/`ensureAgentSwarmProgress`/`removeAgentSwarmProgress` 的精确 API 执行时核对 `subagent-event-handler.ts`。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/controllers/subagent-event-handler.test.ts` +Expected: FAIL,`requestRender(true)` 未被调用。 + +- [ ] **Step 3: 修改 `removeAgentSwarmProgress`** + +```ts +// apps/kimi-code/src/tui/controllers/subagent-event-handler.ts:546 +removeAgentSwarmProgress(toolCallId: string): void { + const idx = this.state.transcriptContainer.children.findIndex( + (c) => c === this.agentSwarmProgress, + ); + if (idx !== -1) { + this.state.transcriptContainer.children.splice(idx, 1); + this.agentSwarmProgress = undefined; + // Removing the panel shrinks the transcript; force a full render so the + // differential renderer does not leave stale rows in scrollback. + this.state.ui.requestRender(true); + } +} +``` + +> 执行时核对现有 `removeAgentSwarmProgress` 的实际实现(变量名、是否已有 `requestRender` 调用),在其末尾追加 `requestRender(true)`。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/controllers/subagent-event-handler.test.ts` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/controllers/subagent-event-handler.ts apps/kimi-code/test/tui/controllers/subagent-event-handler.test.ts +git commit -m "fix(tui): force full render when removing AgentSwarm progress panel" +``` + +--- + +## Task 9: Todo 清空时临时占位(#7) + +**Files:** +- Modify: `apps/kimi-code/src/tui/components/chrome/todo-panel.ts:114` +- Modify: `apps/kimi-code/src/tui/controllers/streaming-ui.ts:583、618、646` +- Test: `apps/kimi-code/test/tui/components/panels/todo-panel.test.ts`(调整 + 追加用例) + +`todos.length === 0` 时 `render()` 返回 `[]`,高度归零,editor 上移。改为:清空时**临时占位到上一次渲染高度**(空行);新增 `clearPlaceholder()`,在 AI 新一轮输出开始(`onStreamingTextStart`/`onToolCallStart`/`onThinkingUpdate` 首次)时调用,撤销占位。 + +- [ ] **Step 1: 调整 + 追加测试** + +```ts +// 在 todo-panel.test.ts 内 + +// 调整原有用例:空面板初始仍返回 [] +it('returns no lines when empty before any todos were shown', () => { + const panel = new TodoPanelComponent(); + expect(panel.render(80)).toEqual([]); + expect(panel.isEmpty()).toBe(true); +}); + +// 新增:清空后占位到上次高度 +it('holds placeholder lines at the last height after clearing', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([ + { title: 'a', status: 'done' }, + { title: 'b', status: 'in_progress' }, + ]); + const before = panel.render(80).length; // header + 2 rows + panel.setTodos([]); // 清空 + const after = panel.render(80); + expect(after).toHaveLength(before); // 等高占位 + expect(after.every((line) => line.trim() === '')).toBe(true); +}); + +// 新增:clearPlaceholder 撤销占位 +it('clearPlaceholder removes the placeholder lines', () => { + const panel = new TodoPanelComponent(); + panel.setTodos([{ title: 'a', status: 'done' }]); + panel.render(80); + panel.setTodos([]); + expect(panel.render(80).length).toBeGreaterThan(0); + panel.clearPlaceholder(); + expect(panel.render(80)).toEqual([]); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/panels/todo-panel.test.ts` +Expected: FAIL,清空后返回 `[]` 而非占位。 + +- [ ] **Step 3: 修改 `TodoPanelComponent`** + +```ts +// apps/kimi-code/src/tui/components/chrome/todo-panel.ts +export class TodoPanelComponent implements Component { + private lastRenderedLines = 0; + private placeholderLines = 0; + // ... 已有字段 + + setTodos(todos: TodoItem[]): void { + const wasEmpty = this.todos.length === 0; + this.todos = [...todos]; + if (!wasEmpty && this.todos.length === 0) { + // 从有到无:占位到上次高度,等下一轮 AI 输出时再撤销 + this.placeholderLines = this.lastRenderedLines; + } else if (this.todos.length > 0) { + this.placeholderLines = 0; + } + } + + clearPlaceholder(): void { + this.placeholderLines = 0; + } + + render(width: number): string[] { + if (this.todos.length === 0) { + if (this.placeholderLines > 0) { + return Array.from({ length: this.placeholderLines }, () => ''); + } + return []; + } + // ... 原有渲染逻辑 + const lines = /* ... */ []; + this.lastRenderedLines = lines.length; + return lines; + } +} +``` + +> 注意:`lastRenderedLines` 只在「有 todo」时更新,避免占位帧把自身高度记成 0。`clear()` 方法也需要 `placeholderLines = 0`(执行时核对)。 + +- [ ] **Step 4: 在 `streaming-ui` 接入 `clearPlaceholder`** + +```ts +// apps/kimi-code/src/tui/controllers/streaming-ui.ts +onStreamingTextStart(): void { + this.state.todoPanel.clearPlaceholder(); + // ... 原有逻辑 +} + +onToolCallStart(toolCall: ToolCallBlockData): void { + this.state.todoPanel.clearPlaceholder(); + // ... 原有逻辑 +} + +onThinkingUpdate(fullText: string): void { + if (!this.hasThinkingStarted) { + this.state.todoPanel.clearPlaceholder(); + this.hasThinkingStarted = true; + } + // ... 原有逻辑 +} +``` + +> `this.state.todoPanel` 引用执行时核对(`streaming-ui.ts:708` 已有 `setTodos` 调用,说明引用存在)。`hasThinkingStarted` 标志用于「首次 thinking」判断,执行时按现有结构对齐。 + +- [ ] **Step 5: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/panels/todo-panel.test.ts` +Expected: PASS。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/kimi-code/src/tui/components/chrome/todo-panel.ts apps/kimi-code/src/tui/controllers/streaming-ui.ts apps/kimi-code/test/tui/components/panels/todo-panel.test.ts +git commit -m "fix(tui): hold temporary placeholder height when todo panel clears" +``` + +--- + +## Task 10: Todo Ctrl+T 展开/折叠时全量重绘(#8) + +**Files:** +- Modify: `apps/kimi-code/src/tui/kimi-tui.ts:2367-2370` +- Test: `apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts`(追加用例) + +`toggleTodoPanelExpansion()` 当前用普通 `requestRender()`(`kimi-tui.ts:2369`)。Ctrl+T 展开/折叠时 Todo 面板高度变化(展开显示全部、折叠显示 5 行 + 提示),差量渲染会在 scrollback 留下脏内容、editor 位置残留。改为 `requestRender(true)`,与 `Ctrl+O`(`kimi-tui.ts:2364`,同一函数上方几行已有)一致。`TodoPanelComponent.render()` 逻辑**保持不变**——靠全量重绘保证视觉干净,不做等高化/内部滚动。 + +- [ ] **Step 1: 写失败测试** + +```ts +// 在 kimi-tui-message-flow.test.ts 内追加(或新建 todo-panel-expansion.test.ts) +it('toggleTodoPanelExpansion forces a full render', () => { + const tui = createTestTui(); // 用文件内已有 helper + const requestRender = vi.spyOn(tui.state.ui, 'requestRender'); + tui.toggleTodoPanelExpansion(); + expect(requestRender).toHaveBeenCalledWith(true); +}); +``` + +> 若 `createTestTui`/`KimiTUI` 实例化成本过高,退而测试契约:spy `state.ui.requestRender`,调用 `toggleTodoPanelExpansion()`,断言以 `true` 调用。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/kimi-tui-message-flow.test.ts` +Expected: FAIL,`requestRender` 被调用但参数不是 `true`。 + +- [ ] **Step 3: 修改 `toggleTodoPanelExpansion`** + +```ts +// apps/kimi-code/src/tui/kimi-tui.ts:2367 +toggleTodoPanelExpansion(): void { + this.state.todoPanel.toggleExpanded(); + // Expanding/collapsing the todo panel shifts the editor vertically; the + // clamped differential render leaves stale rows in scrollback. Force a full + // render, matching the Ctrl+O toggle a few lines above. + this.state.ui.requestRender(true); +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/kimi-tui-message-flow.test.ts` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/kimi-code/src/tui/kimi-tui.ts apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +git commit -m "fix(tui): force full render when toggling todo panel expansion" +``` + +--- + +## Task 11: BTW 关闭全量重绘 + Write/Edit preview capped(#9、#10) + +**Files:** +- Modify: `apps/kimi-code/src/tui/controllers/btw-panel.ts:141-144` +- Modify: `apps/kimi-code/src/tui/components/messages/tool-call.ts:1904-1980` +- Test: `apps/kimi-code/test/tui/components/messages/tool-call.test.ts`(追加用例) + +**#9 BTW Esc 关闭**:`close()` 当前 `btwPanelContainer.clear()` 后普通渲染,高度归零 + 脏内容。改为 `clear()` 后 `requestRender(true)`。 + +**#10 Write/Edit preview**:发现 Edit 有 capped 缺口。`buildCallPreview:1924` 的 `shouldCap = this.result !== undefined && !this.expanded`,导致 Edit 在 **args finalize(result 未到)时 `shouldCap=false`,渲染完整 diff**(`renderDiffLinesClustered` 不传 `maxLines`),result 到达后才 capped 到 10 行 → 严重塌缩(即 `tool-call.ts:1931-1934` 注释里 "snap back" 的场景,但只修了 Write 没修 Edit)。Write 已 capped,但 streaming 取尾、finalize 取头,内容突变且行数差 1。修复:Edit args finalize 时即 capped,且 capped 时用 **tail 模式**(取最后 10 行变更,和 Write streaming 一致);Write streaming 补提示行与 finalize 对齐。`renderDiffLinesClustered` 新增 `tail` 选项。 + +- [ ] **Step 1: 写测试** + +```ts +// btw-panel 关闭(执行时确认是否有 controller 测试文件,无则新建) +it('forces a full render when closing the BTW panel', () => { + const { controller, ui } = createBtwController(); + const requestRender = vi.spyOn(ui, 'requestRender'); + controller.openForTest(); // 按实际 API 打开 + requestRender.mockClear(); + controller.closeForTest(); // 触发 Esc 关闭 + expect(requestRender).toHaveBeenCalledWith(true); +}); + +// Edit:args finalize 时就 capped,result 到达后不塌缩 +it('caps Edit preview at args finalize, before result lands', () => { + const tc = new ToolCallComponent( + { + id: 'e1', + name: 'Edit', + args: { + file_path: 'a.ts', + old_string: manyLines(40), + new_string: manyLines(40), + }, + }, + undefined, + stubTui(), + ); + const argsLines = tc.render(120).length; // args finalize,result 未到 + tc.setResult({ output: 'ok', is_error: false }); + const resultLines = tc.render(120).length; + // 两态都 capped 到 COMMAND_PREVIEW_LINES,高度差 ≤ 1(提示行) + expect(Math.abs(resultLines - argsLines)).toBeLessThanOrEqual(1); +}); + +// Write:streaming 与 finalized 行数一致 +it('keeps Write preview line count stable across streaming and finalized', () => { + const streaming = new ToolCallComponent( + { + id: 'w1', + name: 'Write', + args: {}, + streamingArguments: buildStreamingArgs('a.ts', 50), + } as any, + undefined, + stubTui(), + ); + const streamingLines = streaming.render(120).length; + const finalized = new ToolCallComponent( + { id: 'w1', name: 'Write', args: { file_path: 'a.ts', content: manyLines(50) } }, + undefined, + stubTui(), + ); + const finalLines = finalized.render(120).length; + expect(Math.abs(streamingLines - finalLines)).toBeLessThanOrEqual(1); +}); +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/tool-call.test.ts test/tui/components/media/diff-preview.test.ts` +Expected: FAIL。Edit 测试在 args finalize 时未 capped(完整 diff),result 到达后塌缩;Write 测试 streaming 无提示行,行数差 1;BTW 测试 `requestRender(true)` 未被调用;diff-preview tail 模式未实现。 + +- [ ] **Step 3: 修改 BTW `close`** + +```ts +// apps/kimi-code/src/tui/controllers/btw-panel.ts:141 +private close(panel: BtwPanelComponent): void { + if (!this.host.state.btwPanelContainer.children.includes(panel)) return; + this.host.state.btwPanelContainer.clear(); + this.unregister(panel); + // Closing the panel shrinks the chrome area above the editor; force a full + // render to clear stale rows and reposition the editor. + this.host.state.ui.requestRender(true); +} +``` + +> `this.unregister(panel)` 调用执行时核对现有 `close` 实现(可能已有)。`this.host.state.ui` 引用执行时核对 `BtwPanelHost` 接口。 + +- [ ] **Step 4a: 给 `renderDiffLinesClustered` 加 tail 选项** + +`apps/kimi-code/src/tui/components/media/diff-preview.ts:233`:在 `ClusteredDiffOptions` 加 `tail?: boolean`。tail=true 时保留 header,对 body 取最后 `maxLines` 行,前面加 `… N earlier lines hidden` 提示(和 Write streaming 的尾部语义一致)。 + +先写测试 `apps/kimi-code/test/tui/components/media/diff-preview.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; + +function lines(n: number, prefix = 'line'): string { + return Array.from({ length: n }, (_, i) => `${prefix} ${i + 1}`).join('\n'); +} + +describe('renderDiffLinesClustered tail mode', () => { + it('keeps the tail when tail=true and exceeds maxLines', () => { + const out = renderDiffLinesClustered(lines(40), lines(40, 'changed'), 'a.ts', { + maxLines: 10, + tail: true, + }); + const joined = out.join('\n'); + expect(joined).toContain('earlier lines hidden'); + expect(joined).toContain('changed 40'); // 尾部变更保留 + expect(joined).not.toContain('changed 1'); // 头部变更被裁 + }); + + it('keeps the head when tail=false', () => { + const out = renderDiffLinesClustered(lines(40), lines(40, 'changed'), 'a.ts', { + maxLines: 10, + tail: false, + }); + const joined = out.join('\n'); + expect(joined).toContain('more changes hidden'); + expect(joined).toContain('changed 1'); + }); +}); +``` + +实现:在 `ClusteredDiffOptions` 加 `tail?: boolean`,函数末尾 return 前加 tail 分支: + +```ts +if (opts.tail && maxLines !== undefined && output.length > maxLines) { + const header = output[0]!; + const body = output.slice(1); + const keep = Math.max(1, maxLines - 1); // 留 1 行给提示 + const hidden = body.length - keep; + const hint = opts.expandKeyHint ?? 'ctrl+o'; + return [ + header, + s.meta(` … ${String(hidden)} earlier lines hidden (${hint} to expand)`), + ...body.slice(body.length - keep), + ]; +} +``` + +> tail 模式需要先渲染完整 output(不被循环内 `cap` 截断),再做尾部截断。执行时把循环的 `cap` 在 tail 模式下设为 `Infinity`,或先生成完整 output 再走 tail 分支。 + +- [ ] **Step 4b: Edit preview 用 tail 模式 capped** + +`tool-call.ts:1953-1964` `buildCallPreview` 的 Edit 分支: + +```ts +} else if (name === 'Edit') { + const oldStr = str(this.toolCall.args['old_string']); + const newStr = str(this.toolCall.args['new_string']); + if (oldStr.length === 0 && newStr.length === 0) return; + const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); + // Cap as soon as args finalize (not just when result lands), and keep the + // tail so the latest changes stay visible — matches Write streaming. + const editShouldCap = !this.expanded; + const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { + contextLines: 3, + ...(editShouldCap ? { maxLines: COMMAND_PREVIEW_LINES, tail: true } : {}), + }); + for (const line of lines) { + this.addChild(new Text(line, 2, 0)); + } +} +``` + +- [ ] **Step 5: 修复 Write streaming 提示行** + +`tool-call.ts:2005-2016` `buildStreamingPreview` 的 Write 分支:streaming 取尾 10 行后,补一行 `... N more` 提示,和 finalized(`buildCallPreview:1942-1951`)行数对齐(都 11 行)。 + +```ts +if (name === 'Write') { + // ... 现有 content/filePath/lang/allLines/scrollLines 逻辑 + for (const [i, line] of scrollLines.entries()) { + const originalLineNumber = + allLines.length > maxLines ? allLines.length - maxLines + i : i; + const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); + this.addChild(new Text(lineNum + line, 2, 0)); + } + // 补提示行,与 finalized 行数对齐 + if (allLines.length > maxLines) { + const remaining = allLines.length - scrollLines.length; + this.addChild( + new Text( + currentTheme.dim( + `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, + ), + 2, + 0, + ), + ); + } + return; +} +``` + +- [ ] **Step 6: 运行测试,确认通过** + +Run: `pnpm --filter @moonshot-ai/kimi-code test test/tui/components/messages/tool-call.test.ts test/tui/components/media/diff-preview.test.ts` +Expected: PASS。 + +- [ ] **Step 7: Commit** + +```bash +git add apps/kimi-code/src/tui/controllers/btw-panel.ts apps/kimi-code/src/tui/components/messages/tool-call.ts apps/kimi-code/src/tui/components/media/diff-preview.ts apps/kimi-code/test/tui/components/messages/tool-call.test.ts apps/kimi-code/test/tui/components/media/diff-preview.test.ts +git commit -m "fix(tui): force full render on BTW close and cap Edit/Write preview at args finalize" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- #1a restoreEditor → Task 2 ✓ +- #1b autocomplete 关闭 → Task 3 ✓ +- #2 AgentGroup → Task 4 ✓ +- #3 Thinking → Task 5 ✓ +- #4 ToolCall 输出窗口 → Task 6 ✓ +- #5 ShellRun → Task 7 ✓ +- #6 AgentSwarm 摘除 → Task 8 ✓ +- #7 Todo 临时占位 → Task 9 ✓ +- #8 Todo Ctrl+T → Task 10 ✓ +- #9 BTW 关闭 → Task 11 ✓ +- #10 Write/Edit preview → Task 11 ✓ +- 共享基础设施 → Task 1 ✓ + +**2. Placeholder scan:** +- 无 TBD/TODO。所有「执行时核对」均指向明确的 API 名称与文件行号,属于接口确认而非空缺。 +- Task 6(ToolCall 输出窗口)是最复杂重构,已说明需先完整阅读 `tool-call.ts` 相关方法再实施。 + +**3. Type consistency:** +- `FixedHeightWindow({ height, lines, tail })` + `setLines()` 在 Task 1/5/6/7 一致。 +- `clearPlaceholder()` 在 Task 9 定义并被 `streaming-ui` 调用,一致。 +- `requestRender(true)` 在 Task 2/3/8/11 一致。 + +**4. 风险与权衡(执行时关注):** +- **Task 6 窗口行数 6**:若简单工具调用留白过多,可调小 `OUTPUT_WINDOW_LINES`;若 diff/长输出被截断过多,可调大。统一常量便于调整。 +- **Task 4 done 第二行内容**:直接用 `snap.latestActivity`(完成态已是 `Used {name} ({keyArg})`),无活动时回退到 `'Completed'`/`'Backgrounded'`。无需新增 snapshot 字段。 +- **反馈 2(bash 长输出执行完消失)**:属于 `ToolCallComponent` 的 `buildLiveOutputBlock`/`buildProgressBlock`,已在 Task 6 覆盖(固定 6 行输出窗口,bash tail 不清空),AgentGroup 本身不渲染 bash 输出。 +- **Task 9 占位常驻**:占位只在「todo 清空 → 下一轮 AI 输出」之间存在;若用户长时间不触发新输出,会保留空白。可接受。 +- **全量重绘的性能**:Task 2/3/8/11 的 `requestRender(true)` 都是用户主动触发的离散事件(关闭 dialog/autocomplete/BTW、AgentSwarm 结束),非高频,性能可接受。 + +--- + +## 实施记录(2026-07-04) + +所有 11 个任务已实施完成,测试全部通过(311 tests),oxlint 通过。 + +### 方案调整(实施时的工程决策) + +- **Task 5(Thinking 等高化)**:原计划用 `FixedHeightWindow` 把 live/finalized 统一固定为 4 行。实施后发现短内容(≤2 行)会被 padding 出空行(finalized 在 contentLines=1 时有 2 行空行),不符合预期。改为:contentLines > 2 时固定 4 行(live/finalized 等高),contentLines ≤ 2 时恢复自然高度、不 padding(允许 live/finalized 差 1 行,避免空行)。Thinking 不再使用 `FixedHeightWindow`。 + +- **Task 6(ToolCall 输出窗口)**:原计划是「把 progress + liveOutput + content 合并成一个固定 6 行窗口」。实施时改为「progress 行固定 6 行窗口(`PROGRESS_WINDOW_LINES = 6`,tail)」,因为 content 由 `pickResultRenderer` 返回 `Component[]`(含 diff/image 等复杂组件),合并成单一文本窗口风险高且会丢失样式。新方案把运行中 progress 从最多 24 行降到 6 行,大幅减少 `setResult` 清空时的塌缩;liveOutput 仍 tail 3 行。`buildProgressBlock` 改用 `FixedHeightWindow`,新增 `styleProgressLine` 提取样式逻辑。 + +- **Task 11 #9(BTW 关闭)**:代码里 `btw-panel.ts:147` 的 `close()` 已经在调用 `this.host.state.ui.requestRender(true)`,无需额外改动。 + +- **Task 11 #10(Write/Edit)**: + - Edit:`buildCallPreview` 改用 `editShouldCap = !this.expanded`,args finalize 时即 capped;并传 `tail: true` 给 `renderDiffLinesClustered`,超过 10 行时保留尾部变更。 + - Write:`buildStreamingPreview` 在 streaming 末尾补 `... N more lines ... ctrl+o to expand` 提示行,与 finalized 行数对齐。 + - `renderDiffLinesClustered` 新增 `tail?: boolean` 选项(tail 模式下 `cap = Infinity` 渲染完整后取尾部)。 + - 删除了不再使用的 `shouldCap` 变量(oxlint 报错)。 + +### 关键实现细节 + +- `CustomEditor` 用 `this.tui.requestRender(true)`(pi-tui `Editor` 的属性是 `protected tui`,不是 `ui`)。 +- `AgentGroup` 的 detach hint 占位用 `Spacer(1)`(`Text('')` 渲染为空数组,不占行)。 +- `TodoPanel` 的占位通过 `placeholderLines` + `lastRenderedLines` 实现,`clearPlaceholder()` 在 `streaming-ui` 的 `onStreamingTextStart` / `onThinkingUpdate`(首次)/ `onToolCallStart` 调用。 + +### 未做的事 + +- 未自动 commit(按系统约束,git mutation 需用户确认)。所有改动在工作区。 + +--- + +## 执行交接 + +Plan complete and saved to `plan/2026-07-04-dynamic-ui-height-fix.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach?