Skip to content

Commit 6de524c

Browse files
committed
Keep the agents panel live on wall clock, and stop rebuilding rows unchanged
Critique review found two gaps in the agents panel: its elapsed clock and stalled flag only refreshed on the next unrelated chrome event (goal change, subagent progress), so a worker that went quiet with no further events never flipped to stalled; and setChromeZones rebuilt every agent row's TextRenderable on every call, including pushes that only touched goal or task. The sticky poll now repaints the agents panel on its own 200ms tick, matching the cadence already used for the transcript trailer. Row rebuild is now skipped unless the panel's actual lines changed.
1 parent b4d94f5 commit 6de524c

4 files changed

Lines changed: 86 additions & 3 deletions

File tree

src/tui-opentui/product-host.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ async function mountHeadless(
4747
host: Awaited<ReturnType<typeof mountProductHost>>
4848
emitter: EventEmitter
4949
destroyHarness: () => void
50+
renderOnce: () => Promise<void>
51+
captureCharFrame: () => string
5052
}> {
5153
const harness = await createHarness({ width: 80, height: 24 })
5254
const emitter = new EventEmitter()
@@ -60,7 +62,13 @@ async function mountHeadless(
6062
createRenderer: async () => harness.renderer,
6163
...overrides,
6264
})
63-
return { host, emitter, destroyHarness: harness.destroy }
65+
return {
66+
host,
67+
emitter,
68+
destroyHarness: harness.destroy,
69+
renderOnce: harness.renderOnce,
70+
captureCharFrame: harness.captureCharFrame,
71+
}
6472
}
6573

6674
function makeRequest(
@@ -270,6 +278,36 @@ describe("mountProductHost", () => {
270278
).not.toThrow()
271279
expect(host.shell.streamLog).toEqual([])
272280
})
281+
282+
test("the agents panel's elapsed clock advances on the sticky poll tick, without another chrome push", async () => {
283+
const now = Date.now()
284+
const { host, renderOnce, captureCharFrame } = await mountHeadless({
285+
chrome: {
286+
agents: [
287+
{
288+
agentId: "explore",
289+
description: "map callers",
290+
status: "running",
291+
startedAt: now - 59_000,
292+
lastActivityAt: now,
293+
},
294+
],
295+
},
296+
})
297+
try {
298+
await renderOnce()
299+
expect(captureCharFrame()).toContain("0:59")
300+
301+
// No further chrome push or event — only wall-clock time passing.
302+
// Only the 200ms sticky poll can be responsible for the clock moving.
303+
await new Promise((r) => setTimeout(r, 1_100))
304+
await renderOnce()
305+
expect(captureCharFrame()).not.toContain("0:59")
306+
expect(captureCharFrame()).toMatch(/1:0\d/)
307+
} finally {
308+
host.dispose()
309+
}
310+
})
273311
})
274312

275313
describe("provider-first model picker", () => {

src/tui-opentui/product-host.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,11 @@ export async function mountProductHost(
392392
if (config.subAgentSessions !== undefined) {
393393
bridge.syncAgentProgress(config.subAgentSessions())
394394
}
395+
// The agents panel's elapsed clock and stalled flag are a function of
396+
// wall time, not just of the last event — repaint on the same tick as
397+
// the transcript trailer so a worker that goes quiet still flips to
398+
// "stalled" without waiting on an unrelated chrome push.
399+
if (chromeState !== null) paintChromeZones()
395400
} catch {
396401
clearInterval(stickyPoll)
397402
}

src/tui-opentui/shell.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4084,8 +4084,13 @@ export function setChromeZones(
40844084
if (content.task !== undefined) {
40854085
bag.chrome.task = content.task ?? ""
40864086
}
4087+
let agentsChanged = false
40874088
if (content.agents !== undefined) {
4088-
bag.chrome.agents = content.agents ?? []
4089+
const next = content.agents ?? []
4090+
agentsChanged =
4091+
next.length !== bag.chrome.agents.length ||
4092+
next.some((line, i) => line !== bag.chrome.agents[i])
4093+
bag.chrome.agents = next
40894094
}
40904095

40914096
const goalOn = bag.chrome.goal.length > 0
@@ -4095,7 +4100,10 @@ export function setChromeZones(
40954100

40964101
shell.goalText.content = goalOn ? ` ${bag.chrome.goal}` : ""
40974102
shell.taskText.content = taskOn ? ` ${bag.chrome.task}` : ""
4098-
renderAgentsRows(shell, bag.chrome.agents)
4103+
// Rebuilding N TextRenderable children is real node churn; skip it unless
4104+
// the panel's actual lines changed (not every goal/task/agents push carries
4105+
// new agent data).
4106+
if (agentsChanged) renderAgentsRows(shell, bag.chrome.agents)
40994107

41004108
// Only a zone appearing/disappearing or its row count changing alters the
41014109
// row budget; retitling a zone whose row count is unchanged must not

src/tui-opentui/wave6.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,38 @@ describe("Wave 6: chrome zones", () => {
321321
{ width: 80, height: 24 },
322322
)
323323
})
324+
325+
test("agents panel rows are only rebuilt when the panel's lines actually change", async () => {
326+
await withTestRenderer(
327+
async (h) => {
328+
const shell = createAppShell(h.renderer, {
329+
terminal: { columns: 80, rows: 24 },
330+
wireKeys: false,
331+
})
332+
try {
333+
setChromeZones(shell, { agents: ["explore: map callers"] })
334+
const rowsBefore = [...shell.agentsBox.getChildren()]
335+
expect(rowsBefore).toHaveLength(1)
336+
337+
// An unrelated goal push must not touch the agents rows.
338+
setChromeZones(shell, { goal: "goal: unrelated" })
339+
expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore)
340+
341+
// Pushing the exact same agent lines again must not rebuild either.
342+
setChromeZones(shell, { agents: ["explore: map callers"] })
343+
expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore)
344+
345+
// Changed lines must rebuild.
346+
setChromeZones(shell, { agents: ["explore: map callers · 0:01"] })
347+
expect([...shell.agentsBox.getChildren()]).not.toEqual(rowsBefore)
348+
expect(shell.agentsBox.getChildren()).toHaveLength(1)
349+
} finally {
350+
shell.dispose()
351+
}
352+
},
353+
{ width: 80, height: 24 },
354+
)
355+
})
324356
})
325357

326358
describe("Wave 6: keyboard copy path", () => {

0 commit comments

Comments
 (0)