fix(cli): opentui preview pane budget, row clip, and title charge from the #12559 R5 review

This commit is contained in:
qwen-code[bot] committed 2026-10-08 10:44:05 +00:00
1 parent 0ae8a86b36
commit 5ebd14b350
7 files changed
+220 -64

No files matched your search

@@ -379,6 +379,58 @@ describe('OpenTuiArenaDialog select pane charging', () => {
expect(screen.queryByText('model-a3')).toBeNull();
});
it('clips the preview pane to the rows the region leaves', async () => {
// The pane's rows were charged against the list window but the pane
// itself painted unbounded: a long approachSummary grew the
// unshrinkable frame past a sixteen-row region (12 chrome + 9 preview
// rows into 16). The pane clips its runs to the region's leftover —
// margin, title and two approach rows — and drops the runs that no
// longer fit.
const longApproachManager = {
getAgentStates: () =>
[0, 1].map((i) => ({
agentId: `a${i}`,
model: { modelId: `model-a${i}` },
status: AgentStatus.COMPLETED,
stats: { durationMs: 1000, outputTokens: 42 },
})),
getResult: () => ({
task: 'task',
agents: [0, 1].map((i) => ({
agentId: `a${i}`,
model: { modelId: `model-a${i}` },
approachSummary: 'w'.repeat(320),
stats: { outputTokens: 42, durationMs: 1000, toolCalls: 1 },
diffSummary: { additions: 1, deletions: 0, files: [] },
})),
}),
};
const longApproachConfig = {
getArenaManager: () => longApproachManager,
} as unknown as Config;
render(
<OpenTuiArenaDialog
mode="select"
config={longApproachConfig}
onClose={() => {}}
notify={() => {}}
availableTerminalHeight={16}
/>,
);
await press('p');
expect(screen.getByText(/Quick Preview · model-a0/)).toBeTruthy();
const text = document.body.textContent ?? '';
// Two approach rows at the run's seventy-eight columns, not the five
// rows the 320-column run wraps into unclipped.
expect(text.includes('w'.repeat(156))).toBe(true);
expect(text.includes('w'.repeat(157))).toBe(false);
// The leftover rows are spent on the approach; the files and metrics
// runs stay unpainted rather than growing the frame past the region.
expect(screen.queryByText('Major files:')).toBeNull();
expect(screen.queryByText('Metrics:')).toBeNull();
});
it('caps the detailed diff at the rows the region leaves and pays its chrome', async () => {
// The diff pane pays its margin and title (2) plus as many lines as fit:
// the list yields its zero-row floor, so six of forty lines paint with a
+84 -51
View File
@@ -39,6 +39,7 @@ import { formatDuration } from '../utils/formatters.js';
import { getArenaStatusLabel } from '../utils/displayUtils.js';
import { toOriginalKey } from './key-map.js';
import {
clipToRows,
findNextEnabledIndex,
getSelectionScrollOffset,
wrappedRows,
@@ -642,31 +643,70 @@ function formatFileList(files: string[]): string {
return `${visible.join(', ')}${suffix}`;
}
interface AgentPreviewRun {
label: string;
value: string;
}
interface ClippedAgentPreview {
title: string;
runs: AgentPreviewRun[];
/** The rows the clipped pane paints: its margin and title, then the runs. */
rows: number;
}
/**
* The rows AgentPreview paints: its margin and title, then each content run
* measured at the width the run's box actually gets — the pane is indented
* two columns and each value starts after its label, so a value wraps within
* what the label leaves.
* The preview pane's runs, clipped so the pane's row count never exceeds
* `rowBudget`: the pane's rows come out of the list's window, and the
* approach run — LLM-generated, with no length bound — can otherwise grow
* the unshrinkable frame past the clipped region. Runs clip in paint order
* (the unbounded approach first), and a run the budget can no longer pay
* does not paint at all. Each run is clipped and re-measured at the width
* its box actually gets — the pane is indented two columns and each value
* starts after its label. Undefined when the budget cannot pay the pane's
* own margin and title.
*/
function measureAgentPreviewRows(
function clipAgentPreview(
result: ArenaAgentResult,
frameContentWidth: number,
): number {
const runWidth = Math.max(1, frameContentWidth - 2);
const approach = result.approachSummary ?? 'No approach summary available.';
const files = formatFileList(
(result.diffSummary?.files ?? []).map((f) => f.path),
);
const metrics = `${result.stats.outputTokens.toLocaleString()} tokens · ${formatDuration(result.stats.durationMs)} · ${result.stats.toolCalls} tools`;
const run = (label: string, value: string) =>
wrappedRows(value, Math.max(1, runWidth - getCachedStringWidth(label)));
return (
wrappedRows(`Quick Preview · ${result.model.modelId}`, frameContentWidth) +
1 + // the pane's marginTop
run('Approach: ', approach) +
run('Major files: ', files) +
run('Metrics: ', metrics)
);
rowBudget: number | undefined,
): ClippedAgentPreview | undefined {
const title = `Quick Preview · ${result.model.modelId}`;
const naturalRuns: AgentPreviewRun[] = [
{
label: 'Approach: ',
value: result.approachSummary ?? 'No approach summary available.',
},
{
label: 'Major files: ',
value: formatFileList(
(result.diffSummary?.files ?? []).map((f) => f.path),
),
},
{
label: 'Metrics: ',
value: `${result.stats.outputTokens.toLocaleString()} tokens · ${formatDuration(result.stats.durationMs)} · ${result.stats.toolCalls} tools`,
},
];
const runWidth = (label: string) =>
Math.max(1, frameContentWidth - 2 - getCachedStringWidth(label));
// The pane's marginTop plus the row(s) its title wraps into.
const chrome = 1 + wrappedRows(title, frameContentWidth);
if (rowBudget !== undefined && rowBudget < chrome) return undefined;
let remaining =
rowBudget === undefined ? Number.MAX_SAFE_INTEGER : rowBudget - chrome;
const runs: AgentPreviewRun[] = [];
let rows = chrome;
for (const run of naturalRuns) {
if (remaining < 1) break;
const width = runWidth(run.label);
const value = clipToRows(run.value, width, remaining);
const paid = wrappedRows(value, width);
runs.push({ label: run.label, value });
rows += paid;
remaining -= paid;
}
return { title, runs, rows };
}
/**
@@ -685,29 +725,18 @@ function cappedDiffLines(
];
}
function AgentPreview({ result }: { result: ArenaAgentResult }) {
const files = result.diffSummary?.files ?? [];
function AgentPreview({ preview }: { preview: ClippedAgentPreview }) {
return (
<box marginTop={1} flexDirection="column">
<text fg={C.text} attributes={1}>
{`Quick Preview · ${result.model.modelId}`}
{preview.title}
</text>
<box marginLeft={2} flexDirection="row">
<text fg={C.dim}>{'Approach: '}</text>
<text fg={C.text}>
{result.approachSummary ?? 'No approach summary available.'}
</text>
</box>
<box marginLeft={2} flexDirection="row">
<text fg={C.dim}>{'Major files: '}</text>
<text fg={C.text}>{formatFileList(files.map((f) => f.path))}</text>
</box>
<box marginLeft={2} flexDirection="row">
<text fg={C.dim}>{'Metrics: '}</text>
<text
fg={C.text}
>{`${result.stats.outputTokens.toLocaleString()} tokens · ${formatDuration(result.stats.durationMs)} · ${result.stats.toolCalls} tools`}</text>
</box>
{preview.runs.map((run) => (
<box key={run.label} marginLeft={2} flexDirection="row">
<text fg={C.dim}>{run.label}</text>
<text fg={C.text}>{run.value}</text>
</box>
))}
</box>
);
}
@@ -893,16 +922,22 @@ function ArenaSelect({
// An open pane's rows come out of the list's window: the frame is
// unshrinkable inside the clipped region, so a pane added on top of a full
// window grows the frame past the region and the clip takes the pane the
// user opened it to read. The preview's runs are measured at the width
// they paint in (they word-wrap); the detailed diff gets a line cap from
// what the list's zero-row floor leaves, because its 181-line ceiling can
// never fit a region.
const previewRows =
showPreview && selectedResult
? measureAgentPreviewRows(selectedResult, frameContentWidth)
: 0;
// user opened it to read. Both panes cap to what the region leaves: the
// preview clips its runs to the leftover rows (reserving the diff pane's
// two chrome rows when both are open), and the detailed diff gets a line
// cap from what the list's zero-row floor leaves, because its 181-line
// ceiling can never fit a region.
const diffOpen = Boolean(showDetailedDiff && selectedResult);
const diffLines = diffOpen ? visibleDiffLines(selectedResult?.diff) : [];
const previewBudget =
regionHeight === undefined
? undefined
: Math.max(0, regionHeight - 12 - (diffOpen ? 2 : 0));
const preview =
showPreview && selectedResult
? clipAgentPreview(selectedResult, frameContentWidth, previewBudget)
: undefined;
const previewRows = preview?.rows ?? 0;
let agentWindowRows: number;
let diffLineCap: number | undefined;
if (regionHeight === undefined) {
@@ -1062,9 +1097,7 @@ function ArenaSelect({
);
})}
</box>
{showPreview && selectedResult && (
<AgentPreview result={selectedResult} />
)}
{preview && <AgentPreview preview={preview} />}
{showDetailedDiff && selectedResult && (
<AgentDetailedDiff
result={selectedResult}
+23 -1
View File
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getCachedStringWidth, toCodePoints } from '../utils/textUtils.js';
import {
clipToWidth,
getCachedStringWidth,
toCodePoints,
} from '../utils/textUtils.js';
/**
* Pure dialog machinery for the OpenTUI dialog family (PR1 slice 3).
@@ -134,6 +138,24 @@ export function wrappedRows(text: string, width: number): number {
return rows;
}
/**
* The longest prefix of `text` whose word wrap at `width` columns pays at
* most `rows` rows. A column clip alone under-pays: greedy word wrap leaves
* the row a long token starts on partly empty, so `width * rows` columns can
* wrap into `rows + 1` rows. The column budget walks down until the measured
* rows fit; at zero the empty string still costs the one row an emptied
* value pays, so callers must not ask for zero rows.
*/
export function clipToRows(text: string, width: number, rows: number): string {
let budget = width * rows;
let clipped = clipToWidth(text, budget);
while (budget > 0 && wrappedRows(clipped, width) > rows) {
budget -= 1;
clipped = clipToWidth(text, budget);
}
return clipped;
}
// The renderer's own width table paints the warning sign in one column where
// string-width counts two, so the row charge is measured with it painted as
// one — otherwise the shipped warning is overcharged a row at narrow widths.
@@ -100,6 +100,7 @@ import {
type McpServerInfo,
type McpToolInfo,
} from './dialogs-mcp.js';
import { wrappedRows } from './dialogs-core.js';
function baseKeyEvent(overrides: Record<string, unknown> = {}) {
return {
@@ -398,13 +399,18 @@ describe('OpenTuiMcpDialog list windows', () => {
);
await press('return'); // server list → server detail
// The error's leading rows paint, clipped to the four rows it is
// charged (six-row window less the spacer and the action row).
// The error's leading rows paint, clipped to the three rows it is
// charged (the six-row window less the spacer and the two action rows).
expect(screen.getByText(/Failed to parse:/)).toBeTruthy();
const text = document.body.textContent ?? '';
expect(text.includes('e'.repeat(500))).toBe(false);
// The action row below the error still paints.
expect(screen.getByText('Disable')).toBeTruthy();
// The clip pays rows, not columns: a bare column clip lets greedy word
// wrap leave the row the long token starts partly empty, so the painted
// value wraps into one more row than the three it is charged.
const painted = screen.getByText(/Failed to parse:/).textContent ?? '';
expect(wrappedRows(painted, 72)).toBeLessThanOrEqual(3);
});
it('refuses the resource list keys at a zero-row window', async () => {
+4 -2
View File
@@ -30,6 +30,7 @@ import {
dialogContentWidth,
} from './dialogs-shared.js';
import {
clipToRows,
findNextEnabledIndex,
followScrollOffset,
wrappedRows,
@@ -1046,9 +1047,10 @@ export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) {
<text fg={row.red ? C.red : C.text}>{row.label}</text>
</box>
<text fg={row.red ? C.red : C.text}>
{clipToWidth(
{clipToRows(
sanitizeTerminalLine(row.value),
detailValueWidth * chargedRows,
detailValueWidth,
chargedRows,
)}
</text>
</box>
@@ -1073,6 +1073,26 @@ describe('OpenTuiOutputStyleDialog', () => {
expect(queryRow('Concise — ')).toBeNull();
});
it('bounds the title charge the same way at a thirty-column terminal', async () => {
// The subtitle-remainder charge wedged /output-style the same way:
// fifteen chrome rows against a fourteen-row region paid the catalog no
// row at all.
mocks.state.width = 30;
const harness = createHarness();
render(
<OpenTuiOutputStyleDialog
config={harness.config}
settings={harness.settings}
onClose={vi.fn()}
notify={vi.fn()}
availableTerminalHeight={14}
/>,
);
await waitFor(() => expect(queryRow('default — ')).not.toBeNull());
expect(queryRow('Concise — ')).not.toBeNull();
});
it('pays both catalog rows a twelve-row region fits at full width', async () => {
// At width 100 the title needs its margin row only and the footer hint
// fits one row, so the true chrome is 4 + 2 + 2 = 8 and region twelve
@@ -1575,7 +1595,10 @@ describe('OpenTuiEffortDialog', () => {
mocks.state.width = 100;
});
function renderEffortDialog(reasoningEffort: string | undefined) {
function renderEffortDialog(
reasoningEffort: string | undefined,
availableTerminalHeight?: number,
) {
const setValue = vi.fn();
const notify = vi.fn();
let applied = reasoningEffort;
@@ -1604,6 +1627,7 @@ describe('OpenTuiEffortDialog', () => {
settings={settings}
onClose={vi.fn()}
notify={notify}
availableTerminalHeight={availableTerminalHeight}
/>,
);
return { setValue, setReasoningEffort, notify };
@@ -1678,6 +1702,20 @@ describe('OpenTuiEffortDialog', () => {
screen.getByText('(applied across all providers; clamped per model)'),
).not.toBeNull();
});
it('bounds the title charge at the width the two runs shrink into', () => {
// At a thirty-column terminal the title run and its subtitle overflow
// the row together and the renderer shrinks both; charging the subtitle
// the one-column remainder instead counted seventeen title rows, the
// chrome outgrew a twenty-row region, the window paid zero rows and
// Enter committed nothing.
mocks.state.width = 30;
const harness = renderEffortDialog(undefined, 20);
expect(queryRow('high — ')).not.toBeNull();
press('return');
expect(harness.setReasoningEffort).toHaveBeenCalledWith('high');
});
});
describe('OpenTuiSettingsDialog region budget', () => {
+10 -7
View File
@@ -214,7 +214,11 @@ function noticeRows(text: string | null, contentWidth: number): number {
* The rows a non-truncating DialogTitle paints: the subtitle is an adjacent
* run that wraps within the columns the title leaves, and the row box is as
* tall as the taller of the two — so the budget pays the measured maximum,
* not a flat row.
* not a flat row. When the two runs overflow the row together the renderer
* shrinks both, so the split charge is bounded by the runs wrapped as one
* (plus the partial row each shrunken box can waste): an unbounded remainder
* charge counts a one-column leftover a dozen rows the box never paints, and
* the chrome alone can then outgrow the region.
*/
function dialogTitleRows(
title: string,
@@ -224,13 +228,12 @@ function dialogTitleRows(
const titleRun = `> ${title} `;
const titleRowCount = wrappedRows(titleRun, contentWidth);
if (!subtitle) return titleRowCount;
return Math.max(
titleRowCount,
wrappedRows(
subtitle,
Math.max(1, contentWidth - getCachedStringWidth(titleRun)),
),
const splitRows = wrappedRows(
subtitle,
Math.max(1, contentWidth - getCachedStringWidth(titleRun)),
);
const ceiling = wrappedRows(`${titleRun}${subtitle}`, contentWidth) + 1;
return Math.min(Math.max(titleRowCount, splitRows), ceiling);
}
/**