fix(chat): bound terminal output expansion
This commit is contained in:
@@ -59,7 +59,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output bypasses the throttle and receives the normal one-time highlighted rendering.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
|
||||
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
||||
|
||||
## "I want to change description for Perplexity" (example recipe)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { renderTerminalOutput } from './toolOutput';
|
||||
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
|
||||
describe('getToolOutput', () => {
|
||||
@@ -76,6 +75,36 @@ describe('renderTerminalOutput', () => {
|
||||
expect(result).toBe('A'.repeat(50000));
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('bounds synthetic rows from large cursor coordinates', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999Bdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('bounds synthetic columns from large cursor coordinates', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999Cdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('shares the synthetic allocation budget across cursor movements', () => {
|
||||
const result = renderTerminalOutput('\u001B[50001B\u001B[999999999Cdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('bounds absolute cursor row and column coordinates', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999;999999999Hdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('bounds absolute cursor columns', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999Gdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStreamingOutputAppend', () => {
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
const ensureLine = (lines: string[][], row: number): void => {
|
||||
const MAX_SYNTHETIC_TERMINAL_CELLS = 100_000;
|
||||
|
||||
interface TerminalRenderBudget {
|
||||
syntheticCells: number;
|
||||
}
|
||||
|
||||
const ensureLine = (lines: string[][], requestedRow: number, budget: TerminalRenderBudget): number => {
|
||||
const missingRows = Math.max(0, requestedRow - lines.length + 1);
|
||||
const availableCells = MAX_SYNTHETIC_TERMINAL_CELLS - budget.syntheticCells;
|
||||
const addedRows = Math.min(missingRows, availableCells);
|
||||
const row = Math.min(requestedRow, lines.length + addedRows - 1);
|
||||
|
||||
while (lines.length <= row) {
|
||||
lines.push([]);
|
||||
}
|
||||
budget.syntheticCells += addedRows;
|
||||
return row;
|
||||
};
|
||||
|
||||
const writeTerminalCharacter = (lines: string[][], row: number, column: number, character: string): void => {
|
||||
ensureLine(lines, row);
|
||||
const writeTerminalCharacter = (
|
||||
lines: string[][],
|
||||
row: number,
|
||||
requestedColumn: number,
|
||||
character: string,
|
||||
budget: TerminalRenderBudget,
|
||||
): number => {
|
||||
const line = lines[row];
|
||||
const availableCells = MAX_SYNTHETIC_TERMINAL_CELLS - budget.syntheticCells;
|
||||
const column = Math.min(requestedColumn, line.length + availableCells);
|
||||
const padding = Math.max(0, column - line.length);
|
||||
while (line.length < column) {
|
||||
line.push(' ');
|
||||
}
|
||||
budget.syntheticCells += padding;
|
||||
line[column] = character;
|
||||
return column;
|
||||
};
|
||||
|
||||
export const renderTerminalOutput = (output: string): string => {
|
||||
@@ -19,6 +42,7 @@ export const renderTerminalOutput = (output: string): string => {
|
||||
}
|
||||
|
||||
const lines: string[][] = [[]];
|
||||
const budget: TerminalRenderBudget = { syntheticCells: 0 };
|
||||
let row = 0;
|
||||
let column = 0;
|
||||
|
||||
@@ -28,7 +52,7 @@ export const renderTerminalOutput = (output: string): string => {
|
||||
if (character === '\n') {
|
||||
row += 1;
|
||||
column = 0;
|
||||
ensureLine(lines, row);
|
||||
lines[row] ??= [];
|
||||
continue;
|
||||
}
|
||||
if (character === '\r') {
|
||||
@@ -40,8 +64,7 @@ export const renderTerminalOutput = (output: string): string => {
|
||||
continue;
|
||||
}
|
||||
if (character !== '\u001B') {
|
||||
writeTerminalCharacter(lines, row, column, character);
|
||||
column += 1;
|
||||
column = writeTerminalCharacter(lines, row, column, character, budget) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -62,8 +85,7 @@ export const renderTerminalOutput = (output: string): string => {
|
||||
if (command === 'A') {
|
||||
row = Math.max(0, row - count);
|
||||
} else if (command === 'B') {
|
||||
row += count;
|
||||
ensureLine(lines, row);
|
||||
row = ensureLine(lines, row + count, budget);
|
||||
} else if (command === 'C') {
|
||||
column += count;
|
||||
} else if (command === 'D') {
|
||||
@@ -71,11 +93,9 @@ export const renderTerminalOutput = (output: string): string => {
|
||||
} else if (command === 'G') {
|
||||
column = Math.max(0, count - 1);
|
||||
} else if (command === 'H' || command === 'f') {
|
||||
row = Math.max(0, (parameters[0] || 1) - 1);
|
||||
row = ensureLine(lines, Math.max(0, (parameters[0] || 1) - 1), budget);
|
||||
column = Math.max(0, (parameters[1] || 1) - 1);
|
||||
ensureLine(lines, row);
|
||||
} else if (command === 'K') {
|
||||
ensureLine(lines, row);
|
||||
const line = lines[row];
|
||||
const mode = parameters[0];
|
||||
if (mode === 1) {
|
||||
|
||||
Reference in New Issue
Block a user