diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 6331c149a8..04d6ccb0d9 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -16,6 +16,7 @@ const createAnsiToken = (overrides: Partial): AnsiToken => ({ underline: false, dim: false, inverse: false, + isUninitialized: false, fg: '#ffffff', bg: '#000000', ...overrides, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index d079a289ee..c7e5df8750 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -352,6 +352,7 @@ describe('', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], ]; diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 7e0f3125a5..f30c309898 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -28,6 +28,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], ]; @@ -179,6 +180,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], ]; @@ -275,6 +277,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], [ @@ -287,6 +290,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], [ @@ -299,6 +303,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], [ @@ -311,6 +316,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], [ @@ -323,6 +329,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ], ]; @@ -362,6 +369,7 @@ describe('ToolResultDisplay', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ]); const renderResult = await renderWithProviders( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index a2494a0a8b..cd06d93616 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -74,6 +74,7 @@ describe('ToolResultDisplay Overflow', () => { underline: false, dim: false, inverse: false, + isUninitialized: false, }, ]); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( diff --git a/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx index d9af4fbcfa..34d05ebc70 100644 --- a/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx @@ -541,6 +541,7 @@ describe('useExecutionLifecycle', () => { italic: false, underline: false, inverse: false, + isUninitialized: false, }, ], ]; diff --git a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts index 4af4084813..2e80bf8f95 100644 --- a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts @@ -554,6 +554,7 @@ export const useExecutionLifecycle = ( italic: false, underline: false, inverse: false, + isUninitialized: false, }, ]); return [...newLines, [], ...output]; diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 0f41c55671..a7b21ebefc 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -155,6 +155,7 @@ const createMockSerializeTerminalToObjectReturnValue = ( underline: false, dim: false, inverse: false, + isUninitialized: false, fg: '#ffffff', bg: '#000000', }, @@ -173,6 +174,7 @@ const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => { underline: false, dim: false, inverse: false, + isUninitialized: false, fg: '', bg: '', } as AnsiToken, diff --git a/packages/core/src/utils/terminalSerializer.test.ts b/packages/core/src/utils/terminalSerializer.test.ts index cfc8032141..de069829db 100644 --- a/packages/core/src/utils/terminalSerializer.test.ts +++ b/packages/core/src/utils/terminalSerializer.test.ts @@ -30,11 +30,12 @@ describe('terminalSerializer', () => { allowProposedApi: true, }); const result = serializeTerminalToObject(terminal); - expect(result).toHaveLength(24); + expect(result).toHaveLength(1); result.forEach((line) => { // Expect each line to be either empty or contain a single token with spaces + // Actually, the first cell will have inverse: true (cursor), so it will have multiple tokens if (line.length > 0) { - expect(line[0].text.trim()).toBe(''); + expect(line[line.length - 1].text.trim()).toBe(''); } }); }); diff --git a/packages/core/src/utils/terminalSerializer.ts b/packages/core/src/utils/terminalSerializer.ts index a764e8bff3..545bb5fe86 100644 --- a/packages/core/src/utils/terminalSerializer.ts +++ b/packages/core/src/utils/terminalSerializer.ts @@ -12,6 +12,7 @@ export interface AnsiToken { underline: boolean; dim: boolean; inverse: boolean; + isUninitialized: boolean; fg: string; bg: string; } @@ -126,6 +127,12 @@ class Cell { return this.cell?.getChars() || ' '; } + isUninitialized(): boolean { + return this.cell + ? this.cell.getCode() === 0 && this.cell.isAttributeDefault() + : true; + } + isAttribute(attribute: Attribute): boolean { return (this.attributes & attribute) !== 0; } @@ -137,7 +144,8 @@ class Cell { this.bg === other.bg && this.fgColorMode === other.fgColorMode && this.bgColorMode === other.bgColorMode && - this.isCursor() === other.isCursor() + this.isCursor() === other.isCursor() && + this.isUninitialized() === other.isUninitialized() ); } } @@ -149,15 +157,15 @@ export function serializeTerminalToObject( ): AnsiOutput { const buffer = terminal.buffer.active; const cursorX = buffer.cursorX; - const cursorY = buffer.cursorY; + const absoluteCursorY = buffer.baseY + buffer.cursorY; const defaultFg = ''; const defaultBg = ''; const result: AnsiOutput = []; // Reuse cell instances - const lastCell = new Cell(null, -1, -1, cursorX, cursorY); - const currentCell = new Cell(null, -1, -1, cursorX, cursorY); + const lastCell = new Cell(null, -1, -1, cursorX, absoluteCursorY); + const currentCell = new Cell(null, -1, -1, cursorX, absoluteCursorY); const effectiveStart = startLine ?? buffer.viewportY; const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows; @@ -173,12 +181,12 @@ export function serializeTerminalToObject( } // Reset lastCell for new line - lastCell.update(null, -1, -1, cursorX, cursorY); + lastCell.update(null, -1, -1, cursorX, absoluteCursorY); let currentText = ''; for (let x = 0; x < terminal.cols; x++) { const cellData = line.getCell(x, cellBuffer); - currentCell.update(cellData || null, x, y, cursorX, cursorY); + currentCell.update(cellData || null, x, y, cursorX, absoluteCursorY); if (x > 0 && !currentCell.equals(lastCell)) { if (currentText) { @@ -190,6 +198,7 @@ export function serializeTerminalToObject( dim: lastCell.isAttribute(Attribute.dim), inverse: lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(), + isUninitialized: lastCell.isUninitialized(), fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg), bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg), }; @@ -200,7 +209,7 @@ export function serializeTerminalToObject( currentText += currentCell.getChars(); // Copy state from currentCell to lastCell. Since we can't easily deep copy // without allocating, we just update lastCell with the same data. - lastCell.update(cellData || null, x, y, cursorX, cursorY); + lastCell.update(cellData || null, x, y, cursorX, absoluteCursorY); } if (currentText) { @@ -211,6 +220,7 @@ export function serializeTerminalToObject( underline: lastCell.isAttribute(Attribute.underline), dim: lastCell.isAttribute(Attribute.dim), inverse: lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(), + isUninitialized: lastCell.isUninitialized(), fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg), bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg), }; @@ -220,6 +230,23 @@ export function serializeTerminalToObject( result.push(currentLine); } + // Remove trailing empty lines + while (result.length > 0) { + const lastLine = result[result.length - 1]; + const lineY = effectiveStart + result.length - 1; + + // A line is empty if all its tokens are marked as uninitialized and it has no cursor + const isEmpty = + lastLine.every((token) => token.isUninitialized && !token.inverse) && + lineY !== absoluteCursorY; + + if (isEmpty) { + result.pop(); + } else { + break; + } + } + return result; }