mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 23:01:29 -10:00
fix(cli): opentui preview pane budget, row clip, and title charge from the #12559 R5 review
This commit is contained in:
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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user