diff --git a/.changeset/fix-write-preview-scrollback.md b/.changeset/fix-write-preview-scrollback.md new file mode 100644 index 000000000..a8fab28e4 --- /dev/null +++ b/.changeset/fix-write-preview-scrollback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Improve the Write tool UX. 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 6e225c84f..fae50c645 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1390,10 +1390,6 @@ export class ToolCallComponent extends Container { this.buildStreamingPreview(this.toolCall.streamingArguments); return; } - // Collapse only kicks in once the result lands (bullet turns - // green). Between args-finalize and result we may sit in approval - // for a while — keep the preview fully visible during that gap so - // the user reviews the full change in context. const shouldCap = this.result !== undefined && !this.expanded; if (name === 'Write') { const content = str(this.toolCall.args['content']); @@ -1401,13 +1397,18 @@ export class ToolCallComponent extends Container { const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); const lang = langFromPath(filePath); const allLines = highlightLines(content, lang); - const shown = shouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; + // Cap as soon as args finalize, not just when result lands. Otherwise the + // brief render tick between finalized args and result draws the full file, + // and the snap back to the collapsed cap triggers pi-tui's full-redraw + // path which wipes the terminal scrollback (pre-TUI history). + const writeShouldCap = !this.expanded; + const shown = writeShouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; const remaining = allLines.length - shown.length; for (const [i, line] of shown.entries()) { const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } - if (shouldCap && remaining > 0) { + if (writeShouldCap && remaining > 0) { this.addChild( new Text( chalk.dim( @@ -1455,8 +1456,17 @@ export class ToolCallComponent extends Container { ''; const lang = langFromPath(filePath); const allLines = highlightLines(content, lang); - for (const [i, line] of allLines.entries()) { - const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); + const maxLines = COMMAND_PREVIEW_LINES; + const scrollLines = + allLines.length > maxLines + ? allLines.slice(allLines.length - maxLines) + : allLines; + for (const [i, line] of scrollLines.entries()) { + const originalLineNumber = + allLines.length > maxLines + ? allLines.length - maxLines + i + : i; + const lineNum = chalk.dim(String(originalLineNumber + 1).padStart(4) + ' '); this.addChild(new Text(lineNum + line, 2, 0)); } return; 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 a71c6619b..68d299d83 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 @@ -733,7 +733,7 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('Used Agent'); }); - it('renders the full Write content during the streaming delta window (no cap)', () => { + it('scrolls the Write streaming preview to the last COMMAND_PREVIEW_LINES', () => { const lines: string[] = []; for (let i = 1; i <= 30; i++) lines.push(`line${String(i)}`); const escaped = lines.join('\\n'); @@ -750,10 +750,14 @@ describe('ToolCallComponent', () => { const out = strip(component.render(100).join('\n')); expect(out).toContain('Using Write'); - // All 30 lines must be present — no 10-line cap during streaming. - expect(out).toContain('line1'); - expect(out).toContain('line25'); + // Streaming preview caps at COMMAND_PREVIEW_LINES (10) and shows the tail. + expect(out).not.toContain('line1'); + expect(out).not.toContain('line20'); + expect(out).toContain('line21'); expect(out).toContain('line30'); + // Line numbers should reflect actual file positions. + expect(out).toContain(' 21'); + expect(out).toContain(' 30'); expect(out).not.toContain('ctrl+o to expand'); }); @@ -824,12 +828,15 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('ctrl+o to expand'); }); - it('keeps Write preview uncapped between finalized args and result (approval window)', () => { + it('caps the Write preview between finalized args and result to keep transcript height stable', () => { // The wire sequence is: tool.call.delta → ... → tool.call (final - // args, no streamingArguments) → approval pending ... → tool.result. - // Between tool.call and tool.result the user may sit in an approval - // panel; the preview must stay fully visible during that gap, only - // collapsing once setResult fires (bullet turns green). + // args, no streamingArguments) → tool.result. Between tool.call and + // tool.result we briefly sit with finalized args and no result yet — + // even without an approval panel, at least one render tick can land + // in this state. The preview must stay capped so the transcript + // height does not balloon and then snap back when the result lands; + // a big shrink triggers pi-tui's full-redraw path which wipes the + // terminal scrollback (history before TUI start). const lines: string[] = []; for (let i = 1; i <= 30; i++) lines.push(`line${String(i)}`); const component = new ToolCallComponent( @@ -844,9 +851,10 @@ describe('ToolCallComponent', () => { ); const out = strip(component.render(100).join('\n')); expect(out).toContain('line1'); - expect(out).toContain('line25'); - expect(out).toContain('line30'); - expect(out).not.toContain('ctrl+o to expand'); + expect(out).toContain('line10'); + expect(out).not.toContain('line11'); + expect(out).not.toContain('line25'); + expect(out).toContain('ctrl+o to expand'); }); it('snaps a long Write preview to the collapsed cap when the result arrives', () => {