Skip to content

Commit b394675

Browse files
committed
Never paint the fleet board past the rows it was granted
The formatter sizes the board to its content, but collapse can grant it fewer rows than that. The rows were rendered before the resolver ran and were never clamped to its answer, so a dozen lanes on an 80x24 terminal added thirteen children to a seven-row box: rows painted on top of each other and on the transcript beneath, and the churn tore down text buffers the next repaint then wrote to. Rendering now happens after the resolver has spoken and only ever paints what it granted. Lanes that no longer fit are disclosed the same way the formatter discloses them, so the count stays honest all the way down.
1 parent 737ff59 commit b394675

2 files changed

Lines changed: 66 additions & 22 deletions

File tree

src/tui-opentui/chrome-state.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,46 @@ export function formatAgentsPanel(
269269
return rows
270270
}
271271

272+
/**
273+
* Fit the board into the rows geometry actually granted it.
274+
*
275+
* The formatter sizes the board to its content, but collapse can grant fewer
276+
* rows than that under pressure. Painting the full set anyway overflows the
277+
* zone's box — rows land on top of each other and on whatever is below. So the
278+
* granted height is the last word, and the lanes it costs are disclosed rather
279+
* than dropped in silence.
280+
*/
281+
export function clampBoardRows(
282+
rows: readonly AgentPanelRow[],
283+
height: number,
284+
): readonly AgentPanelRow[] {
285+
if (height <= 0) return []
286+
if (rows.length <= height) return rows
287+
288+
const header = rows[0]
289+
if (header === undefined) return []
290+
const lanes = rows.filter((r) => r.kind === "lane")
291+
292+
// Below a few rows the disclosure line costs more than the lane it displaces,
293+
// so the header carries the count instead — the same trade the formatter makes.
294+
if (height < 4) {
295+
const shown = lanes.slice(0, height - 1)
296+
return [withHiddenCount(header, lanes.length - shown.length), ...shown]
297+
}
298+
299+
const shown = lanes.slice(0, height - 2)
300+
const hidden = lanes.length - shown.length
301+
return [
302+
header,
303+
...shown,
304+
{ label: `+${hidden} more lanes`, tail: "", stalled: false, kind: "more" },
305+
]
306+
}
307+
308+
function withHiddenCount(header: AgentPanelRow, hidden: number): AgentPanelRow {
309+
return hidden > 0 ? { ...header, tail: ` · +${hidden} hidden` } : header
310+
}
311+
272312
/**
273313
* The one-line answer to "is everything fine". Counts run worst-first so that
274314
* a narrow terminal ellipsizes away the routine tail rather than the trouble.

src/tui-opentui/shell.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { homedir } from "node:os"
9-
import type { AgentPanelRow, TaskPanelRow } from "./chrome-state.js"
9+
import { clampBoardRows, type AgentPanelRow, type TaskPanelRow } from "./chrome-state.js"
1010

1111
import {
1212
BoxRenderable,
@@ -4269,32 +4269,36 @@ export function setChromeZones(
42694269
if (taskChanged) {
42704270
renderTasksRows(shell, bag.chrome.task, shell.layout.contentWidth)
42714271
}
4272-
if (agentsChanged) {
4273-
renderAgentsRows(shell, bag.chrome.agents, shell.layout.contentWidth)
4274-
}
4275-
42764272
// Only a zone appearing/disappearing or its row count changing alters the
42774273
// row budget; retitling a zone whose row count is unchanged must not
42784274
// re-resolve and re-apply the whole layout.
4279-
if (
4280-
taskRowCount === bag.visibility.task &&
4281-
agentsRowCount === bag.visibility.agents
4282-
) {
4283-
paintChrome(shell)
4284-
return
4275+
const budgetUnchanged =
4276+
taskRowCount === bag.visibility.task && agentsRowCount === bag.visibility.agents
4277+
if (!budgetUnchanged) {
4278+
relayout(shell, {
4279+
visibility: {
4280+
...bag.visibility,
4281+
task: taskRowCount,
4282+
agents: agentsRowCount,
4283+
},
4284+
overlayMode: bag.overlayMode,
4285+
...(bag.overlayBodyRows !== undefined
4286+
? { overlayBodyRows: bag.overlayBodyRows }
4287+
: {}),
4288+
})
42854289
}
42864290

4287-
relayout(shell, {
4288-
visibility: {
4289-
...bag.visibility,
4290-
task: taskRowCount,
4291-
agents: agentsRowCount,
4292-
},
4293-
overlayMode: bag.overlayMode,
4294-
...(bag.overlayBodyRows !== undefined
4295-
? { overlayBodyRows: bag.overlayBodyRows }
4296-
: {}),
4297-
})
4291+
// Painted after the resolver has spoken, and only ever as many rows as it
4292+
// granted: a board that paints past its box lands on top of the transcript
4293+
// and tears down the renderables underneath it.
4294+
if (agentsChanged || !budgetUnchanged) {
4295+
renderAgentsRows(
4296+
shell,
4297+
clampBoardRows(bag.chrome.agents, shell.layout.heights.agents),
4298+
shell.layout.contentWidth,
4299+
)
4300+
}
4301+
if (budgetUnchanged) paintChrome(shell)
42984302
}
42994303

43004304
/** How long a panel-visibility flash holds the notice row. */

0 commit comments

Comments
 (0)