fix(tui): Improve the Write tool UX. (#81)

* fix(tui): cap Write preview at args-finalize to preserve terminal scrollback

The Write tool preview rendered the full file content between args-finalize
and result, then snapped to the 10-line collapsed cap once the result landed.
That large shrink triggered pi-tui's full-redraw path, which emits \x1b[3J
and wipes the terminal scrollback (including history from before TUI start).

Cap the preview as soon as args finalize so the rendered height stays stable
across the args-finalize → result transition, avoiding the full redraw.

* chore: update changeset message
This commit is contained in:
liruifengv 2026-05-26 19:16:13 +08:00 committed by GitHub
parent 6f22ae48f8
commit 1fbefc9939
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 43 additions and 20 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Improve the Write tool UX.

View file

@ -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;

View file

@ -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', () => {