From 889cd1a543aee13a16a62f89868af32df2f4f05f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:16:13 -0700 Subject: [PATCH 1/3] Add failing coverage for the toggleable task-list panel (CL-5731) The task list that rendered above the prompt box was lost in the OpenTUI cutover: the domain side (task tool writes, director.getTasks(), onTasksChange) survived, but the chrome had no real multi-row consumer, and subscribeChrome was optional wiring that type-checked cleanly even when never invoked. These tests assert the panel renders each task with its own status as a panel distinct from the agents panel, toggles independent of its live data, degrades before the prompt on a short terminal, and that a live chrome push actually reaches the shell end to end. They fail against the current implementation. --- src/tui-opentui/chrome-state.test.ts | 88 +++++++------ src/tui-opentui/geometry.test.ts | 69 ++++++++++ src/tui-opentui/runner-host.test.ts | 65 ++++++++++ src/tui-opentui/wave6.test.ts | 181 ++++++++++++++++++++++++++- 4 files changed, 360 insertions(+), 43 deletions(-) diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index 9bb230372..aa33ba0ef 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -4,7 +4,7 @@ import { chromeFromSession, formatAgentsPanel, formatChromeZones, - formatTaskLine, + formatTasksPanel, type ChromeLiveState, } from "./chrome-state" @@ -30,17 +30,17 @@ describe("formatChromeZones", () => { test("partial: task string only", () => { const out = formatChromeZones({ task: "cutover readiness" }) - expect(out.task).toBe("task: cutover readiness") + expect(out.task).toEqual([{ label: "cutover readiness", status: null }]) expect(out.agents).toBeNull() }) test("full state formats both zones", () => { const state: ChromeLiveState = { - task: { - title: "chrome live helper", - status: "doing", - remaining: 2, - }, + task: [ + { title: "chrome live helper", status: "doing" }, + { title: "wire chrome zone", status: "todo" }, + { title: "wire agents zone", status: "todo" }, + ], agents: [ { agentId: "explore", @@ -58,7 +58,11 @@ describe("formatChromeZones", () => { ], } const out = formatChromeZones(state, NOW) - expect(out.task).toBe("task: chrome live helper (+2)") + expect(out.task).toEqual([ + { label: "chrome live helper", status: "doing" }, + { label: "wire chrome zone", status: "todo" }, + { label: "wire agents zone", status: "todo" }, + ]) expect(out.agents).toEqual([ { label: "explore: map setChromeZones callers", @@ -95,53 +99,54 @@ describe("formatChromeZones", () => { }) }) -describe("formatTaskLine", () => { +describe("formatTasksPanel", () => { test("string / empty", () => { - expect(formatTaskLine(null)).toBeNull() - expect(formatTaskLine("")).toBeNull() - expect(formatTaskLine(" ")).toBeNull() - expect(formatTaskLine("wire host")).toBe("task: wire host") - }) - - test("structured with remaining", () => { - expect( - formatTaskLine({ - title: "format chrome", - status: "doing", - remaining: 1, - }), - ).toBe("task: format chrome (+1)") - }) - - test("terminal structured hides", () => { - expect( - formatTaskLine({ title: "done item", status: "done" }), - ).toBeNull() + expect(formatTasksPanel(null)).toBeNull() + expect(formatTasksPanel("")).toBeNull() + expect(formatTasksPanel(" ")).toBeNull() + expect(formatTasksPanel("wire host")).toEqual([ + { label: "wire host", status: null }, + ]) }) - test("rows pick doing and remaining", () => { + test("each row carries its own status", () => { expect( - formatTaskLine([ + formatTasksPanel([ { title: "first", status: "done" }, { title: "second", status: "doing" }, { title: "third", status: "todo" }, ]), - ).toBe("task: second (+1)") + ).toEqual([ + { label: "first", status: "done" }, + { label: "second", status: "doing" }, + { label: "third", status: "todo" }, + ]) }) - test("rows all terminal hide", () => { + test("terminal (done/cancelled) rows still render — the panel is a live list, not just what remains", () => { expect( - formatTaskLine([ + formatTasksPanel([ { title: "a", status: "done" }, { title: "b", status: "cancelled" }, ]), - ).toBeNull() + ).toEqual([ + { label: "a", status: "done" }, + { label: "b", status: "cancelled" }, + ]) }) - test("does not double-prefix", () => { - expect(formatTaskLine("task: already prefixed")).toBe( - "task: already prefixed", - ) + test("empty array hides", () => { + expect(formatTasksPanel([])).toBeNull() + }) + + test("bounds fan-out to maxVisible plus a +N more row", () => { + const rows = Array.from({ length: 8 }, (_, i) => ({ + title: `task ${i}`, + status: "todo" as const, + })) + const out = formatTasksPanel(rows, 5) + expect(out).toHaveLength(6) + expect(out?.[5]).toEqual({ label: "+3 more", status: null }) }) }) @@ -300,7 +305,10 @@ describe("chromeFromSession", () => { ]) const zones = formatChromeZones(state, NOW) - expect(zones.task).toBe("task: wire catalogs (+1)") + expect(zones.task).toEqual([ + { label: "wire catalogs", status: "doing" }, + { label: "export index", status: "todo" }, + ]) expect(zones.agents).toEqual([ { label: "explore: map callers", tail: " · grep", stalled: false }, ]) diff --git a/src/tui-opentui/geometry.test.ts b/src/tui-opentui/geometry.test.ts index 66f52b485..31599c77e 100644 --- a/src/tui-opentui/geometry.test.ts +++ b/src/tui-opentui/geometry.test.ts @@ -8,6 +8,7 @@ import { PROMPT_CAP_FRACTION, PROMPT_IDLE_ROWS, SIDE_MARGIN, + TASKS_PANEL_MAX_VISIBLE, ZONE_IDS, ZONE_REGISTRY, resolveGeometry, @@ -143,6 +144,74 @@ describe("resolveGeometry — agents panel", () => { }); }); +describe("resolveGeometry — task panel", () => { + test("N tasks request N rows, bounded by the zone max", () => { + for (let n = 0; n <= TASKS_PANEL_MAX_VISIBLE + 3; n++) { + const requested = Math.min(n, TASKS_PANEL_MAX_VISIBLE + 1); + const layout = idle80x24({ visibility: { task: n } }); + expect(layout.heights.task).toBe(requested); + } + }); + + test("zero tasks (empty or hidden) costs zero chrome", () => { + const layout = idle80x24({ visibility: { task: 0 } }); + expect(layout.heights.task).toBe(0); + expect(layout.regions.task).toBeUndefined(); + }); + + test("a large task list never grows the zone past its bounded max", () => { + const layout = idle80x24({ visibility: { task: 50 } }); + expect(layout.heights.task).toBe(ZONE_REGISTRY.task.max); + expect(layout.heights.task).toBe(TASKS_PANEL_MAX_VISIBLE + 1); + }); + + test("task and agents panels are distinct zones with independent budgets", () => { + const layout = idle80x24({ + visibility: { task: 3, agents: 2 }, + }); + expect(layout.heights.task).toBe(3); + expect(layout.heights.agents).toBe(2); + expect(layout.regions.task).not.toEqual(layout.regions.agents); + }); + + test("under pressure the task panel shrinks one row at a time rather than vanishing in one step", () => { + const layout = resolveGeometry({ + terminal: { columns: 80, rows: 20 }, + visibility: { + commandBanner: 1, + settingsNotice: 1, + pluginBanner: true, + task: TASKS_PANEL_MAX_VISIBLE + 1, + }, + }); + expect(layout.heights.task).toBeGreaterThan(0); + expect(layout.heights.task).toBeLessThan(TASKS_PANEL_MAX_VISIBLE + 1); + expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); + }); + + test("on a short terminal the task panel is fully collapsed before the prompt is ever shrunk below its idle rows", () => { + // Shrink the terminal until something has to give. The task panel sits + // ahead of the prompt in COLLAPSE_ORDER, so collapseOnce always drains it + // to zero before touching the prompt — the prompt degrades last, never + // first, so it is never pushed off screen by a competing chrome zone. + for (let rows = 24; rows >= 10; rows--) { + const layout = resolveGeometry({ + terminal: { columns: 80, rows }, + visibility: { task: TASKS_PANEL_MAX_VISIBLE + 1 }, + }); + if (layout.heights.prompt < PROMPT_IDLE_ROWS) { + expect(layout.heights.task).toBe(0); + } + } + }); + + test("the task panel is ahead of the prompt in collapse order", () => { + expect(COLLAPSE_ORDER.indexOf("task")).toBeLessThan( + COLLAPSE_ORDER.indexOf("prompt"), + ); + }); +}); + describe("resolveGeometry — collapse rules", () => { test("collapses optional strips before violating idle floor", () => { // Request every optional strip + tall progress on 24 rows. diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index fc056bc15..ba5fc8adc 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -122,6 +122,63 @@ describe("observeSessionFromSubAgents", () => { }) }) +describe("mountRunnerHost chrome wiring", () => { + // CL-5731: the task-change callback was built (director writes tasks, + // getTasks()/onTasksChange exist) but had no live consumer — the chrome + // push mechanism type-checked fine with `subscribeChrome` omitted, so a + // director's task update never reached the shell. `subscribeChrome` is now + // a required dep (not optional) so that regression cannot type-check + // again, but the type alone does not prove the wiring actually runs: this + // test drives a real notify() call through mountRunnerHost end to end and + // asserts the task panel painted from it, the way the real runner's + // `emitter.emit("tasks", ...)` -> `subscribeChrome` -> `pushChrome` chain + // does. If `subscribeChrome`'s notify callback were ever dropped again + // (e.g. `deps.subscribeChrome?.(pushChrome)` silently no-op on undefined), + // this test fails because the second push never reaches the panel. + test("a live chrome push (subscribeChrome notify) repaints the task panel", async () => { + const harness = await createHarness({ width: 80, height: 24 }) + let liveTasks: readonly { title: string; status: "todo" | "doing" | "done" | "cancelled" }[] = [] + let notify: (() => void) | undefined + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: {}, + onModelSelect: () => {}, + commands: [], + onCommand: () => {}, + chrome: () => ({ tasks: liveTasks, agents: [] }), + subscribeChrome: (n) => { + notify = n + return () => { + notify = undefined + } + }, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + }) + try { + expect(host.shell.taskBox.visible).toBe(false) + expect(notify).toBeDefined() + + // Mirrors createChatDirector's onTasksChange firing after a + // manage_tasks tool call: the live source changes, then the runner + // notifies the host — it does not push the new snapshot itself. + liveTasks = [{ title: "wire task panel", status: "doing" }] + notify?.() + + expect(host.shell.taskBox.visible).toBe(true) + await harness.renderOnce() + const frame = harness.captureCharFrame() + expect(frame).toContain("wire task panel") + } finally { + host.dispose() + harness.destroy() + } + }) +}) + describe("mountRunnerHost command surfaces", () => { test("routes settings and models, and reports surfaces with no data source", async () => { const harness = await createHarness({ width: 80, height: 24 }) @@ -135,6 +192,7 @@ describe("mountRunnerHost command surfaces", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, surfaces: { @@ -185,6 +243,7 @@ describe("mountRunnerHost model picker", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -213,6 +272,7 @@ describe("mountRunnerHost model picker", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -245,6 +305,7 @@ describe("mountRunnerHost model picker", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -280,6 +341,7 @@ describe("mountRunnerHost model picker", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -311,6 +373,7 @@ describe("bottom border cost run", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, readCostSummary: () => fakeCostSummary(), @@ -338,6 +401,7 @@ describe("bottom border cost run", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, readCostSummary: () => fakeCostSummary(), @@ -376,6 +440,7 @@ describe("mountRunnerHost quit key", () => { commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index 379d8558d..c1ec6c267 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -19,9 +19,11 @@ import { openInsetOverlay, openPalette, replaceStreamRowAt, + runPaletteAction, setChromeZones, streamRowAt, streamRowCount, + toggleTasksPanel, } from "./shell" import { createRecordingClipboard } from "./copy-path" import { stringWidth } from "../tui/view/height" @@ -288,7 +290,7 @@ describe("Wave 6: chrome zones", () => { expect(shell.taskBox.visible).toBe(false) setChromeZones(shell, { - task: "task: chrome zones", + task: [{ label: "chrome zones", status: "doing" }], agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) @@ -301,7 +303,7 @@ describe("Wave 6: chrome zones", () => { await h.renderOnce() const frame = h.captureCharFrame() - expect(frame).toContain("task: chrome zones") + expect(frame).toContain("chrome zones") expect(frame).toContain("explore: map callers") setChromeZones(shell, { task: null, agents: null }) @@ -333,7 +335,7 @@ describe("Wave 6: chrome zones", () => { expect(rowsBefore).toHaveLength(1) // An unrelated task push must not touch the agents rows. - setChromeZones(shell, { task: "task: unrelated" }) + setChromeZones(shell, { task: [{ label: "unrelated", status: "todo" }] }) expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) // Pushing the exact same agent lines again must not rebuild either. @@ -437,6 +439,179 @@ describe("Wave 6: chrome zones", () => { }) }) +// CL-5731: the task list and the agents panel are distinct concepts — a +// task is a unit of work with a status, an agent is an executor — and must +// render as distinct panels, never merged. +describe("CL-5731: task list panel", () => { + test("each task entry renders with its own status, distinct from the agents panel", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { + task: [ + { label: "wire task panel", status: "doing" }, + { label: "add toggle", status: "todo" }, + { label: "write docs", status: "done" }, + ], + agents: [{ label: "explore: map callers", tail: "", stalled: false }], + }) + + expect(shell.layout.heights.task).toBe(3) + expect(shell.taskBox.getChildren()).toHaveLength(3) + // A distinct zone/box from the agents panel — not folded into it. + expect(shell.taskBox).not.toBe(shell.agentsBox) + expect(shell.agentsBox.getChildren()).toHaveLength(1) + + await h.renderOnce() + const frame = h.captureCharFrame() + expect(frame).toContain("wire task panel") + expect(frame).toContain("add toggle") + expect(frame).toContain("write docs") + expect(frame).toContain("explore: map callers") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("takes zero vertical space when the task list is empty", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { task: [] }) + expect(shell.layout.heights.task).toBe(0) + expect(shell.taskBox.visible).toBe(false) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("updates live as the task list changes, without touching the agents panel", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { + task: [{ label: "first task", status: "todo" }], + agents: [{ label: "explore: map callers", tail: "", stalled: false }], + }) + const agentsRowsBefore = [...shell.agentsBox.getChildren()] + + setChromeZones(shell, { + task: [{ label: "first task", status: "done" }], + }) + + await h.renderOnce() + const frame = h.captureCharFrame() + expect(frame).toContain("[x] first task") + expect([...shell.agentsBox.getChildren()]).toEqual(agentsRowsBefore) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("toggling hides the panel without losing the live task data, and un-hiding restores it", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { + task: [{ label: "wire toggle", status: "doing" }], + }) + expect(shell.taskBox.visible).toBe(true) + + toggleTasksPanel(shell) + expect(shell.taskBox.visible).toBe(false) + expect(shell.layout.heights.task).toBe(0) + + // A live push while hidden must not resurrect the panel... + setChromeZones(shell, { + task: [{ label: "wire toggle", status: "done" }], + }) + expect(shell.taskBox.visible).toBe(false) + + // ...but un-hiding shows the current data, not a stale snapshot + // from before the hide. + toggleTasksPanel(shell) + expect(shell.taskBox.visible).toBe(true) + await h.renderOnce() + expect(h.captureCharFrame()).toContain("[x] wire toggle") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("the toggle persists across further chrome pushes for the life of the shell (the session)", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { task: [{ label: "a", status: "todo" }] }) + toggleTasksPanel(shell) + expect(shell.taskBox.visible).toBe(false) + + // Several unrelated live pushes later, the hidden choice still holds. + setChromeZones(shell, { task: [{ label: "a", status: "doing" }] }) + setChromeZones(shell, { agents: [{ label: "x: y", tail: "", stalled: false }] }) + setChromeZones(shell, { task: [{ label: "a", status: "done" }] }) + expect(shell.taskBox.visible).toBe(false) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("the palette 'toggle_task' action drives the same toggle", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + setChromeZones(shell, { task: [{ label: "a", status: "todo" }] }) + expect(shell.taskBox.visible).toBe(true) + runPaletteAction(shell, "toggle_task") + expect(shell.taskBox.visible).toBe(false) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) +}) + describe("Wave 6: keyboard copy path", () => { test("enterCopyMode freezes targets and defaults to last", async () => { await withTestRenderer( From 970b0f50b6ab6b9efc47e9e972889b2da28838c2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:17:51 -0700 Subject: [PATCH 2/3] Restore the toggleable task-list panel above the prompt box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task tool's writes and the director's onTasksChange callback survived the OpenTUI cutover, but the chrome zone reading them only ever rendered a single compact summary line, and the only toggle was a demo action that overwrote the live data with hardcoded content. Give tasks their own multi-row panel (chrome-state.ts's formatTasksPanel, mirroring the agents panel's formatAgentsPanel but keyed on status rather than liveness) so each task renders with a bracket status marker, distinct from the agents panel below it. The task zone now takes boolean|number visibility and a real row budget (TASKS_PANEL_MAX_VISIBLE, bounded and shrunk one row at a time under space pressure) the same way agents already did, so it degrades before the prompt box on a short terminal instead of growing unbounded or disappearing in one step. toggleTasksPanel hides/shows the panel independent of its live data — a hidden flag on the shell that persists for the session while the raw task list keeps updating underneath it, so un-hiding shows the current list rather than a stale snapshot. The palette's toggle_task action now drives this for real instead of stuffing fake content into the zone. Also make RunnerHostDeps.subscribeChrome required rather than optional: an omitted subscription used to type-check cleanly while silently freezing the task/agents panels at their mount-time snapshot — the same built-and-never-wired shape as the callback itself. runner-host.test.ts now drives a live subscribeChrome notify through mountRunnerHost end to end and asserts the panel actually repaints from it. --- docs/TUI.md | 39 ++++++++ src/tui-opentui/chrome-state.ts | 101 ++++++++----------- src/tui-opentui/geometry/index.ts | 1 + src/tui-opentui/geometry/resolve.ts | 23 ++++- src/tui-opentui/geometry/zones.ts | 18 +++- src/tui-opentui/keybindings.test.ts | 1 + src/tui-opentui/palette.ts | 4 +- src/tui-opentui/runner-host.ts | 12 ++- src/tui-opentui/shell.ts | 149 ++++++++++++++++++++++------ 9 files changed, 251 insertions(+), 97 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 0a8ecb48c..690a1ad3e 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -98,6 +98,45 @@ uses the bronze/sand/ember chrome ramp and green (`UI.done`) for completion. The one deliberate exception is diff removals, where orange is content (the removed line), not a decision marker, and no decision-marker shares that row. +## The live task list panel + +The `task` chrome zone renders a standing panel above the transcript, one row +per task the task tool has written (`manage_tasks`) — distinct from the +`agents` panel below it. A task is a unit of work with a status; an agent is +an executor with its own context and transcript. The two are never merged +into one panel: `formatTasksPanel` (`src/tui-opentui/chrome-state.ts`) and +`formatAgentsPanel` are separate formatters feeding separate zones with +separate row types (`TaskPanelRow` vs. `AgentPanelRow`). + +Each row shows a bracket status marker (`[ ]` todo, `[~]` doing, `[x]` done, +`[-]` cancelled) ahead of the title. Terminal tasks still render — the panel +is a live list of work, not just what remains — so an operator watching it +sees a task move to `[x]` rather than have it silently vanish. The panel is +bounded to `TASKS_PANEL_MAX_VISIBLE` rows, same shape as the agents panel: a +longer list degrades to a trailing `+N more` row rather than growing the zone +without limit, and it shrinks one row at a time under space pressure +(`COLLAPSE_ORDER` in `geometry/zones.ts`) rather than vanishing in one step. +`task` sits ahead of `agents` in `COLLAPSE_ORDER`, so on a short terminal the +task panel is always fully collapsed before the prompt box is ever touched — +the prompt is never pushed off screen by a competing chrome zone. + +The panel is toggleable independent of its live data: `toggleTasksPanel` +(bound to the `toggle_task` palette action) flips a hidden flag that persists +on the shell for the life of the session, while the live task list keeps +updating underneath it — un-hiding shows the current list, not a stale +snapshot from before the hide. Hidden or empty, the zone costs zero rows. + +The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`), +which calls `onTasksChange` on every `manage_tasks` tool call and on session +resume (`restoreTasks`). The runner forwards that into the OpenTUI host via +`RunnerHostDeps.chrome`/`subscribeChrome` (`src/tui-opentui/runner-host.ts`): +`subscribeChrome` is a required dependency, not optional, because an omitted +subscription used to type-check cleanly while silently leaving the panel +frozen at its mount-time snapshot — a mechanism built and never wired, hidden +behind an optional callback. `runner-host.test.ts` drives a live +`subscribeChrome` notify end to end and asserts the panel actually repaints, +so that class of regression fails a test again if it recurs. + ## The live agents panel The `agents` chrome zone renders a standing panel above the transcript, one diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index 3f598fef0..2a3e4c6cd 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -23,7 +23,7 @@ */ import { agentProgress, DEFAULT_STALL_MS } from "./agent-progress.js" -import { AGENTS_PANEL_MAX_VISIBLE } from "./geometry/zones.js" +import { AGENTS_PANEL_MAX_VISIBLE, TASKS_PANEL_MAX_VISIBLE } from "./geometry/zones.js" import type { ChromeZoneContent } from "./shell.js" /** Subagent row shape for the agents chrome panel (store-agnostic). */ @@ -39,34 +39,33 @@ export type ChromeAgentSession = { readonly lastActivityAt?: number } -/** - * Task / Work chrome input. - * Empty title or all-terminal lists → zone hidden when formatting from tasks[]. - */ -export type ChromeTaskState = { - /** Current doing (or next todo) title. */ - readonly title: string - readonly status?: "todo" | "doing" | "done" | "cancelled" - /** Other active tasks beyond the current one. */ - readonly remaining?: number -} - -/** Lightweight task row for list → compact line. */ +/** Lightweight task row: title + status, as written by the task tool. */ export type ChromeTaskRow = { readonly title: string readonly status: "todo" | "doing" | "done" | "cancelled" } +/** + * One rendered task-panel row. `status` is null for a non-task row (the + * "+N more" trailer, or a bare-string task input with no structured status) + * so the renderer knows not to paint a status marker for it. + */ +export type TaskPanelRow = { + readonly label: string + readonly status: "todo" | "doing" | "done" | "cancelled" | null +} + /** * Full live chrome snapshot. Missing / null fields hide that zone. * Prefer pushing a complete snapshot on every update. */ export type ChromeLiveState = { /** - * Compact task line: string shorthand, structured current task, or a list - * of work rows (formatter picks the active item like Ink TaskView compact). + * Task list: string shorthand (rendered as a single unstyled row) or the + * structured rows the task tool writes. Distinct from `agents` — a task is + * a unit of work with a status, not an executor. */ - readonly task?: ChromeTaskState | ChromeTaskRow[] | string | null + readonly task?: readonly ChromeTaskRow[] | string | null /** Subagent sessions for the strip summary (running preferred). */ readonly agents?: readonly ChromeAgentSession[] | null /** @@ -94,13 +93,14 @@ export type AgentPanelRow = { /** Always-populated result for setChromeZones (null = hide zone). */ export type FormattedChromeZones = { - readonly task: string | null + /** One row per rendered task-panel line (null = hide zone, zero rows). */ + readonly task: readonly TaskPanelRow[] | null /** One row per rendered agents-panel line (null = hide zone, zero rows). */ readonly agents: readonly AgentPanelRow[] | null } /** - * Format structured live state into chrome zone lines for setChromeZones. + * Format structured live state into chrome zone rows for setChromeZones. * * Empty / partial / inactive inputs yield null for the corresponding zone * so geometry collapses that strip (idleDefault 0). @@ -110,7 +110,7 @@ export function formatChromeZones( nowMs: number = Date.now(), ): FormattedChromeZones { return { - task: formatTaskLine(state.task), + task: formatTasksPanel(state.task), agents: formatAgentsPanel(state.agents, state.observe, nowMs), } } @@ -124,43 +124,36 @@ export function chromeZonesContent(state: ChromeLiveState): ChromeZoneContent { return formatChromeZones(state) } -export function formatTaskLine( - task: ChromeTaskState | ChromeTaskRow[] | string | null | undefined, -): string | null { +/** + * Format the live task-list panel: one row per task, bounded to `maxVisible` + * with a trailing "+N more" row, mirroring `formatAgentsPanel`'s shape but + * keyed on status (not liveness) since a task has no clock of its own. + * + * Terminal tasks (done/cancelled) still render — the panel is a live list of + * work, not just what remains — so an operator watching it sees a task move + * to "done" rather than silently vanish. A bare string input renders as one + * row with no status marker: it is free-form summary text, not a task record. + */ +export function formatTasksPanel( + task: readonly ChromeTaskRow[] | string | null | undefined, + maxVisible: number = TASKS_PANEL_MAX_VISIBLE, +): readonly TaskPanelRow[] | null { if (task === null || task === undefined) return null if (typeof task === "string") { const t = task.trim() - return t.length === 0 ? null : compactLine("task", t) - } - - if (Array.isArray(task)) { - return formatTaskLineFromRows(task) + return t.length === 0 ? null : [{ label: t, status: null }] } - const title = task.title.trim() - if (title.length === 0) return null - if (task.status === "done" || task.status === "cancelled") return null + const rows: TaskPanelRow[] = task + .map((t) => ({ label: t.title.trim(), status: t.status })) + .filter((r) => r.label.length > 0) + if (rows.length === 0) return null - const remaining = - task.remaining !== undefined && task.remaining > 0 - ? ` (+${task.remaining})` - : "" - return compactLine("task", `${title}${remaining}`) -} - -function formatTaskLineFromRows(rows: readonly ChromeTaskRow[]): string | null { - const active = rows.filter( - (t) => t.status !== "done" && t.status !== "cancelled", - ) - if (active.length === 0) return null - const doing = active.find((t) => t.status === "doing") - const current = doing ?? active[0]! - const title = current.title.trim() - if (title.length === 0) return null - const remaining = active.length - 1 - const suffix = remaining > 0 ? ` (+${remaining})` : "" - return compactLine("task", `${title}${suffix}`) + const visible = rows.slice(0, maxVisible) + const hidden = rows.length - visible.length + if (hidden > 0) visible.push({ label: `+${hidden} more`, status: null }) + return visible } /** @@ -274,14 +267,6 @@ export function annotateAgentTools( } } -function compactLine(prefix: string, body: string): string { - const b = body.trim() - if (b.length === 0) return `${prefix}:` - // Avoid double-prefix if host already included it. - if (b.toLowerCase().startsWith(`${prefix}:`)) return b - return `${prefix}: ${b}` -} - // --------------------------------------------------------------------------- // Session-shaped → ChromeLiveState (loose mapping for product host push) // --------------------------------------------------------------------------- diff --git a/src/tui-opentui/geometry/index.ts b/src/tui-opentui/geometry/index.ts index c0d13b592..3c264dfe7 100644 --- a/src/tui-opentui/geometry/index.ts +++ b/src/tui-opentui/geometry/index.ts @@ -11,6 +11,7 @@ export { PROMPT_CAP_FRACTION, PROMPT_IDLE_INPUT_ROWS, PROMPT_IDLE_ROWS, + TASKS_PANEL_MAX_VISIBLE, ZONE_IDS, ZONE_REGISTRY, zoneDeclaration, diff --git a/src/tui-opentui/geometry/resolve.ts b/src/tui-opentui/geometry/resolve.ts index b04c6dcd0..ca37a92a9 100644 --- a/src/tui-opentui/geometry/resolve.ts +++ b/src/tui-opentui/geometry/resolve.ts @@ -46,7 +46,8 @@ export type ZoneVisibility = { readonly progress?: boolean | 1 | 2; /** Progress divider (0–1). Default on when progress is shown. */ readonly progressDivider?: boolean; - readonly task?: boolean; + /** Task panel: false/omit = 0 rows; true = 1 row; or an exact row count (bounded by the zone max). */ + readonly task?: boolean | number; /** Agents panel: false/omit = 0 rows; true = 1 row; or an exact row count (bounded by the zone max). */ readonly agents?: boolean | number; readonly pluginBanner?: boolean; @@ -139,7 +140,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights { progress_divider: progressDivider, notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault, prompt: promptRows, - task: vis.task ? 1 : 0, + task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max), agents: clamp(boolOrRows(vis.agents, 1), 0, ZONE_REGISTRY.agents.max), plugin_banner: vis.pluginBanner ? 1 : 0, command_banner: clamp( @@ -236,6 +237,24 @@ function collapseOnce(heights: MutableHeights, collapsed: ZoneId[]): ZoneId | nu return "progress"; } + if (id === "task") { + // Shrink one row at a time rather than zeroing in one step, same + // rationale as "agents" below: a 1-row panel still carries the first + // task plus a "+N more" trailer, so it stays meaningful all the way + // down instead of vanishing under exactly the pressure an operator + // most needs to see it. This is also what keeps the task panel + // degrading before the prompt box: it sits ahead of "agents" and + // every other optional zone in COLLAPSE_ORDER. + if (h > 1) { + heights.task = h - 1; + if (!collapsed.includes("task")) collapsed.push("task"); + return "task"; + } + heights.task = 0; + if (!collapsed.includes("task")) collapsed.push("task"); + return "task"; + } + if (id === "agents") { // Shrink one row at a time rather than zeroing in one step: a 1-row // panel still carries the stalest agent plus a "+N more" trailer diff --git a/src/tui-opentui/geometry/zones.ts b/src/tui-opentui/geometry/zones.ts index 9b7721968..34b6b8ce7 100644 --- a/src/tui-opentui/geometry/zones.ts +++ b/src/tui-opentui/geometry/zones.ts @@ -41,6 +41,13 @@ export type ZoneDeclaration = { */ export const AGENTS_PANEL_MAX_VISIBLE = 5; +/** + * Bound on rendered task rows in the live task-list panel. Mirrors + * AGENTS_PANEL_MAX_VISIBLE: a large task list degrades to a trailing + * "+N more" row instead of growing the zone without limit. + */ +export const TASKS_PANEL_MAX_VISIBLE = 5; + /** * Fixed-with-test budgets from the constitution table. * Residual zones (transcript, overlay_host) use min/max as floor/cap hints; @@ -67,7 +74,16 @@ export const ZONE_REGISTRY: { readonly [K in ZoneId]: ZoneDeclaration } = { idleDefault: 5, alwaysOn: true, }, - task: { id: "task", min: 0, max: 1, idleDefault: 0, alwaysOn: false }, + // One row per task (bounded by TASKS_PANEL_MAX_VISIBLE) plus an optional + // trailing "+N more" row. Distinct panel from `agents`: a task is a unit + // of work with a status, not an executor. + task: { + id: "task", + min: 0, + max: TASKS_PANEL_MAX_VISIBLE + 1, + idleDefault: 0, + alwaysOn: false, + }, // One row per running agent (bounded by AGENTS_PANEL_MAX_VISIBLE) plus an // optional trailing "+N more" row. agents: { diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index c44967776..bb3258228 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -656,6 +656,7 @@ describe("the runner host does not shadow the prompt bindings the catalog claims commands: [], onCommand: () => {}, chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) diff --git a/src/tui-opentui/palette.ts b/src/tui-opentui/palette.ts index b82db4bd7..78403cb88 100644 --- a/src/tui-opentui/palette.ts +++ b/src/tui-opentui/palette.ts @@ -88,8 +88,8 @@ export const DEFAULT_PALETTE_COMMANDS: readonly PaletteCommand[] = [ }, { id: "toggle_task", - label: "Toggle task chrome", - keywords: ["task", "work", "chrome"], + label: "Toggle task list panel", + keywords: ["task", "work", "chrome", "list", "panel"], dispatch: "residual", }, { diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index 9d6ab83a5..78ee83f9b 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -106,8 +106,14 @@ export type RunnerHostDeps = { readonly onCommand: (name: string) => void /** Live chrome snapshot source, read on mount and on every notify. */ readonly chrome: () => ChromeSessionInput - /** Registers a chrome-change notifier; returns an unsubscribe. */ - readonly subscribeChrome?: (notify: () => void) => () => void + /** + * Registers a chrome-change notifier; returns an unsubscribe. Required, not + * optional: an omitted subscription used to type-check cleanly while + * silently leaving the task/agents panels frozen at their mount-time + * snapshot — the exact "mechanism built, never wired" shape this signature + * now makes impossible to omit by accident. + */ + readonly subscribeChrome: (notify: () => void) => () => void /** Live subagent sessions for the palette observe action. */ readonly subAgentSessions: () => readonly SubAgentSession[] /** @@ -278,7 +284,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const pushChrome = (): void => { host.setChrome(chromeFromSession(deps.chrome())) } - const unsubscribeChrome = deps.subscribeChrome?.(pushChrome) + const unsubscribeChrome = deps.subscribeChrome(pushChrome) if (readModelLabel) setPromptModelLabel(host.shell, readModelLabel()) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 47efdee0d..6f956ea57 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -6,7 +6,7 @@ */ import { homedir } from "node:os" -import type { AgentPanelRow } from "./chrome-state.js" +import type { AgentPanelRow, TaskPanelRow } from "./chrome-state.js" import { BoxRenderable, @@ -550,9 +550,13 @@ export type AppShell = { readonly topPad: BoxRenderable /** Blank row below the prompt box (0 on short terminals). */ readonly bottomPad: BoxRenderable - /** Optional chrome zones (constitution task/agents). */ + /** + * Optional chrome zones (constitution task/agents). Distinct panels: a + * task is a unit of work with a status, an agent is an executor. + * One row per rendered task-panel line; rebuilt whenever the line count + * or any row's status changes. + */ readonly taskBox: BoxRenderable - readonly taskText: TextRenderable /** One row per rendered agents-panel line; rebuilt whenever the line count changes. */ readonly agentsBox: BoxRenderable readonly transcript: ScrollBoxRenderable @@ -1877,12 +1881,22 @@ type ShellInternals = { landingAnimating: boolean /** Clock of the last painted mark frame, so a resize can redraw in place. */ landingNowMs: number - /** Chrome text content (empty = zone off). */ + /** Chrome content (empty array = zone off). */ chrome: { - task: string + /** + * Rendered task rows — empty when there is nothing to show OR the panel + * is hidden by the operator toggle. `tasksRaw` holds the live data + * independent of that toggle, so un-hiding shows the current list + * without waiting on the next task-tool write. + */ + task: readonly TaskPanelRow[] + /** Last live task rows pushed via setChromeZones, regardless of hidden state. */ + tasksRaw: readonly TaskPanelRow[] /** Agents panel rows (empty array = zone off), one row per rendered line. */ agents: readonly AgentPanelRow[] } + /** Operator toggle for the task panel; persists for the life of the shell (session). */ + tasksPanelHidden: boolean } const internals = new WeakMap() @@ -4085,16 +4099,7 @@ export function runPaletteAction( return } case "toggle_task": { - const bag = internals.get(shell) - const on = (bag?.chrome.task.length ?? 0) > 0 - setChromeZones(shell, { - task: on ? null : "task: implement Wave 6 acceptance", - }) - appendStreamRow(shell, { - role: "system", - text: on ? "task banner off" : "task banner on", - meta: "task", - }) + toggleTasksPanel(shell) return } case "toggle_agents": { @@ -4145,11 +4150,28 @@ export function runPaletteAction( } export type ChromeZoneContent = { - readonly task?: string | null + /** One row per task-panel line. Null/empty = hide the zone. */ + readonly task?: readonly TaskPanelRow[] | null /** One row per agents-panel line. Null/empty = hide the zone. */ readonly agents?: readonly AgentPanelRow[] | null } +/** Bracket marker per task status; a trailer row (status null) gets none. */ +function taskStatusMarker(status: TaskPanelRow["status"]): string { + switch (status) { + case "todo": + return "[ ] " + case "doing": + return "[~] " + case "done": + return "[x] " + case "cancelled": + return "[-] " + case null: + return "" + } +} + /** * Fit a row's label + tail into `maxWidth` terminal columns, ellipsizing the * label (agentId + description — free-form, model-authored, routinely long, @@ -4177,6 +4199,42 @@ function fitAgentRow(row: AgentPanelRow, maxWidth: number): string { return ` ${sliceToWidth(row.label, budget)}…${row.tail}` } +/** + * Fit a task row's status marker + label into `maxWidth` columns, same + * ellipsis discipline as `fitAgentRow`: the marker (what says done vs. + * pending) is preserved whole, the free-form title is what gives way. + */ +function fitTaskRow(row: TaskPanelRow, maxWidth: number): string { + const marker = taskStatusMarker(row.status) + const full = ` ${marker}${row.label}` + if (stringWidth(full) <= maxWidth) return full + + const leadingSpace = 1 + const ellipsis = 1 + const budget = maxWidth - leadingSpace - stringWidth(marker) - ellipsis + if (budget <= 0) return ` ${sliceToWidth(marker, maxWidth - leadingSpace)}` + return ` ${marker}${sliceToWidth(row.label, budget)}…` +} + +/** Rebuild taskBox's row children to match the requested rows exactly. */ +function renderTasksRows( + shell: AppShell, + rows: readonly TaskPanelRow[], + maxWidth: number, +): void { + for (const child of [...shell.taskBox.getChildren()]) { + shell.taskBox.remove(child) + destroySubtree(child) + } + for (const row of rows) { + const text = new TextRenderable(shell.renderer as CliRenderer, { + content: fitTaskRow(row, maxWidth), + fg: row.status === "done" ? UI.done : row.status === "doing" ? UI.text : UI.textDim, + }) + shell.taskBox.add(text) + } +} + /** Rebuild agentsBox's row children to match the requested rows exactly. */ function renderAgentsRows( shell: AppShell, @@ -4202,6 +4260,16 @@ function renderAgentsRows( * Set agents/task chrome zone content (null/empty = hide zone). * Heights come from geometry resolve — never guessed. */ +function taskRowsEqual(a: readonly TaskPanelRow[], b: readonly TaskPanelRow[]): boolean { + return ( + a.length === b.length && + a.every((row, i) => { + const other = b[i] + return other !== undefined && row.label === other.label && row.status === other.status + }) + ) +} + export function setChromeZones( shell: AppShell, content: ChromeZoneContent, @@ -4209,8 +4277,12 @@ export function setChromeZones( const bag = internals.get(shell) if (!bag) return + let taskChanged = false if (content.task !== undefined) { - bag.chrome.task = content.task ?? "" + bag.chrome.tasksRaw = content.task ?? [] + const rendered = bag.tasksPanelHidden ? [] : bag.chrome.tasksRaw + taskChanged = !taskRowsEqual(rendered, bag.chrome.task) + bag.chrome.task = rendered } let agentsChanged = false if (content.agents !== undefined) { @@ -4229,13 +4301,14 @@ export function setChromeZones( bag.chrome.agents = next } - const taskOn = bag.chrome.task.length > 0 + const taskRowCount = bag.chrome.task.length const agentsRowCount = bag.chrome.agents.length - shell.taskText.content = taskOn ? ` ${bag.chrome.task}` : "" // Rebuilding N TextRenderable children is real node churn; skip it unless - // the panel's actual lines changed (not every task/agents push carries - // new agent data). + // the panel's actual lines changed (not every push carries new data). + if (taskChanged) { + renderTasksRows(shell, bag.chrome.task, shell.layout.contentWidth) + } if (agentsChanged) { renderAgentsRows(shell, bag.chrome.agents, shell.layout.contentWidth) } @@ -4244,7 +4317,7 @@ export function setChromeZones( // row budget; retitling a zone whose row count is unchanged must not // re-resolve and re-apply the whole layout. if ( - taskOn === bag.visibility.task && + taskRowCount === bag.visibility.task && agentsRowCount === bag.visibility.agents ) { paintChrome(shell) @@ -4254,7 +4327,7 @@ export function setChromeZones( relayout(shell, { visibility: { ...bag.visibility, - task: taskOn, + task: taskRowCount, agents: agentsRowCount, }, overlayMode: bag.overlayMode, @@ -4264,6 +4337,25 @@ export function setChromeZones( }) } +/** + * Toggle the task-list panel visible/hidden without touching the live task + * data underneath it — un-hiding shows whatever the task tool last wrote, + * not a stale snapshot from before the hide. The flag lives on the shell's + * internals for the life of the process, i.e. persists for the session. + */ +export function toggleTasksPanel(shell: AppShell): void { + const bag = internals.get(shell) + if (!bag) return + bag.tasksPanelHidden = !bag.tasksPanelHidden + const hiding = bag.tasksPanelHidden + setChromeZones(shell, { task: bag.chrome.tasksRaw }) + appendStreamRow(shell, { + role: "system", + text: hiding ? "task list hidden" : "task list shown", + meta: "task", + }) +} + /** * Enter copy mode (Alt+C / palette copy_active): freeze targets from the * active streamLog, open inset overlay with the last target selected. @@ -4920,15 +5012,10 @@ export function createAppShell( width: "100%", height: 1, flexShrink: 0, + flexDirection: "column", backgroundColor: UI.ground, visible: false, }) - const taskText = new TextRenderable(ctx, { - id: "shell-task-text", - content: "", - fg: UI.inFlight, - }) - taskBox.add(taskText) const agentsBox = new BoxRenderable(ctx, { id: "shell-agents", @@ -5577,7 +5664,6 @@ export function createAppShell( topPad, bottomPad, taskBox, - taskText, agentsBox, transcript, overlayHost, @@ -5675,7 +5761,8 @@ export function createAppShell( landingSuggestionsVisible: true, landingAnimating: false, landingNowMs: 0, - chrome: { task: "", agents: [] }, + chrome: { task: [], tasksRaw: [], agents: [] }, + tasksPanelHidden: false, }) transcriptSpacers.set(shell, transcriptSpacer) if (onCommandOpt) setPaletteOnCommand(shell, onCommandOpt) From ea30c9aa6a2f5ede934ca39cb0fd73f3fde17e6b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:32:02 -0700 Subject: [PATCH 3/3] Address greybeard review: accurate docs, seeded task visibility, drop the string task shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/TUI.md: the subscribeChrome paragraph claimed making it required fixed an observed break; the production caller always passed it, so restate this as closing a shape that could have type-checked while omitted, not a fix for something that broke. - docs/TUI.md: the collapse-order paragraph conflated two mechanisms. COLLAPSE_ORDER/collapseOnce governs what collapse takes from zones ahead of prompt; PROMPT_CAP_FRACTION independently bounds the prompt's own requested height before collapse ever runs. Name both and what each guarantees. - shell.ts defaultVisibility: seed task: 0 alongside agents: 0, so the adjacent comment about avoiding a needless first relayout is true for both row-count fields it now describes. - chrome-state.ts: delete the string member of ChromeLiveState['task'] and formatTasksPanel's string branch. chromeFromSession never produces a string, so the only callers left were its own tests — a back-compat surface for callers this repo owns, which AGENTS.md forbids. Updated demo.ts and the tests that exercised the string shape accordingly. - Reworded 'session-persisted' to 'shell-lifetime, in memory, nothing written to storage' everywhere it appeared (docs, comments, a test title) — that is the actual design, the phrasing just claimed durability it doesn't have. - geometry.test.ts: corrected a test comment that attributed the short- terminal invariant solely to collapse order; across most of the tested range it actually holds via PROMPT_CAP_FRACTION capping the requested prompt before collapse runs at all. --- docs/TUI.md | 39 ++++++++++++++++++---------- src/tui-opentui/chrome-state.test.ts | 16 +++++------- src/tui-opentui/chrome-state.ts | 17 ++++-------- src/tui-opentui/demo.ts | 4 ++- src/tui-opentui/geometry.test.ts | 14 +++++++--- src/tui-opentui/shell.ts | 12 +++++---- src/tui-opentui/wave6.test.ts | 2 +- 7 files changed, 59 insertions(+), 45 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 690a1ad3e..6270600c2 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -116,26 +116,39 @@ bounded to `TASKS_PANEL_MAX_VISIBLE` rows, same shape as the agents panel: a longer list degrades to a trailing `+N more` row rather than growing the zone without limit, and it shrinks one row at a time under space pressure (`COLLAPSE_ORDER` in `geometry/zones.ts`) rather than vanishing in one step. -`task` sits ahead of `agents` in `COLLAPSE_ORDER`, so on a short terminal the -task panel is always fully collapsed before the prompt box is ever touched — -the prompt is never pushed off screen by a competing chrome zone. + +Two independent mechanisms keep the task panel from ever costing the prompt +box a row on a short terminal, and they guarantee different things. +`COLLAPSE_ORDER` places `task` ahead of `prompt`, so `collapseOnce` +(`geometry/resolve.ts`) always drains `task` to zero before it ever reduces +`prompt` — that loop only runs when the transcript floor is not yet met, and +it never touches a zone later in the order while an earlier one still has +rows to give up. Separately, `PROMPT_CAP_FRACTION` in `desiredHeights` caps +how tall a *requested* prompt is allowed to start at (`PROMPT_CAP_FRACTION * +terminal.rows`), independent of collapse and before it ever runs. Neither +mechanism substitutes for the other: the cap bounds the prompt's own growth +on any terminal, tall or short; the collapse order bounds what other zones +are allowed to take from it once the transcript floor is at risk. The panel is toggleable independent of its live data: `toggleTasksPanel` -(bound to the `toggle_task` palette action) flips a hidden flag that persists -on the shell for the life of the session, while the live task list keeps -updating underneath it — un-hiding shows the current list, not a stale -snapshot from before the hide. Hidden or empty, the zone costs zero rows. +(bound to the `toggle_task` palette action) flips a hidden flag held on the +shell for its lifetime — in memory only, nothing written to storage — while +the live task list keeps updating underneath it. Un-hiding shows the current +list, not a stale snapshot from before the hide. Hidden or empty, the zone +costs zero rows. The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`), which calls `onTasksChange` on every `manage_tasks` tool call and on session resume (`restoreTasks`). The runner forwards that into the OpenTUI host via `RunnerHostDeps.chrome`/`subscribeChrome` (`src/tui-opentui/runner-host.ts`): -`subscribeChrome` is a required dependency, not optional, because an omitted -subscription used to type-check cleanly while silently leaving the panel -frozen at its mount-time snapshot — a mechanism built and never wired, hidden -behind an optional callback. `runner-host.test.ts` drives a live -`subscribeChrome` notify end to end and asserts the panel actually repaints, -so that class of regression fails a test again if it recurs. +`subscribeChrome` is a required dependency, not optional. The production +caller has always passed a real subscription, so this did not fix an +observed break; it closes a shape that could have been omitted and would +still have type-checked — the same "callback that types fine when absent" +hazard this feature's own callback (`onTasksChange`) is named after in the +tracking issue. `runner-host.test.ts` drives a live `subscribeChrome` notify +end to end and asserts the panel actually repaints, so an omission would now +fail a test as well as the type checker. ## The live agents panel diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index aa33ba0ef..cd408b21a 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -28,9 +28,11 @@ describe("formatChromeZones", () => { }) }) - test("partial: task string only", () => { - const out = formatChromeZones({ task: "cutover readiness" }) - expect(out.task).toEqual([{ label: "cutover readiness", status: null }]) + test("partial: task rows only", () => { + const out = formatChromeZones({ + task: [{ title: "cutover readiness", status: "doing" }], + }) + expect(out.task).toEqual([{ label: "cutover readiness", status: "doing" }]) expect(out.agents).toBeNull() }) @@ -100,13 +102,9 @@ describe("formatChromeZones", () => { }) describe("formatTasksPanel", () => { - test("string / empty", () => { + test("null / undefined hide the zone", () => { expect(formatTasksPanel(null)).toBeNull() - expect(formatTasksPanel("")).toBeNull() - expect(formatTasksPanel(" ")).toBeNull() - expect(formatTasksPanel("wire host")).toEqual([ - { label: "wire host", status: null }, - ]) + expect(formatTasksPanel(undefined)).toBeNull() }) test("each row carries its own status", () => { diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index 2a3e4c6cd..3289b1bf6 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -61,11 +61,10 @@ export type TaskPanelRow = { */ export type ChromeLiveState = { /** - * Task list: string shorthand (rendered as a single unstyled row) or the - * structured rows the task tool writes. Distinct from `agents` — a task is - * a unit of work with a status, not an executor. + * Task list: the structured rows the task tool writes. Distinct from + * `agents` — a task is a unit of work with a status, not an executor. */ - readonly task?: readonly ChromeTaskRow[] | string | null + readonly task?: readonly ChromeTaskRow[] | null /** Subagent sessions for the strip summary (running preferred). */ readonly agents?: readonly ChromeAgentSession[] | null /** @@ -131,20 +130,14 @@ export function chromeZonesContent(state: ChromeLiveState): ChromeZoneContent { * * Terminal tasks (done/cancelled) still render — the panel is a live list of * work, not just what remains — so an operator watching it sees a task move - * to "done" rather than silently vanish. A bare string input renders as one - * row with no status marker: it is free-form summary text, not a task record. + * to "done" rather than silently vanish. */ export function formatTasksPanel( - task: readonly ChromeTaskRow[] | string | null | undefined, + task: readonly ChromeTaskRow[] | null | undefined, maxVisible: number = TASKS_PANEL_MAX_VISIBLE, ): readonly TaskPanelRow[] | null { if (task === null || task === undefined) return null - if (typeof task === "string") { - const t = task.trim() - return t.length === 0 ? null : [{ label: t, status: null }] - } - const rows: TaskPanelRow[] = task .map((t) => ({ label: t.title.trim(), status: t.status })) .filter((r) => r.label.length > 0) diff --git a/src/tui-opentui/demo.ts b/src/tui-opentui/demo.ts index 9eb88aa8f..556cac591 100644 --- a/src/tui-opentui/demo.ts +++ b/src/tui-opentui/demo.ts @@ -277,7 +277,9 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => { setChromeZones(shell, { task: on ? null - : formatChromeZones({ task: "cutover readiness" }).task, + : formatChromeZones({ + task: [{ title: "cutover readiness", status: "doing" }], + }).task, }) return } diff --git a/src/tui-opentui/geometry.test.ts b/src/tui-opentui/geometry.test.ts index 31599c77e..8179feb29 100644 --- a/src/tui-opentui/geometry.test.ts +++ b/src/tui-opentui/geometry.test.ts @@ -190,10 +190,16 @@ describe("resolveGeometry — task panel", () => { }); test("on a short terminal the task panel is fully collapsed before the prompt is ever shrunk below its idle rows", () => { - // Shrink the terminal until something has to give. The task panel sits - // ahead of the prompt in COLLAPSE_ORDER, so collapseOnce always drains it - // to zero before touching the prompt — the prompt degrades last, never - // first, so it is never pushed off screen by a competing chrome zone. + // Shrink the terminal until something has to give. Two mechanisms can + // land the prompt below its idle rows here: PROMPT_CAP_FRACTION caps the + // *requested* prompt before collapse ever runs (the one that actually + // fires across most of this range, since a short terminal caps prompt + // rows well before a 6-row task panel could account for the deficit on + // its own), and collapseOnce would additionally shrink prompt only after + // draining every zone ahead of it in COLLAPSE_ORDER — task included. + // Either way the invariant holds: whenever prompt is below its idle + // rows, task is already at zero, so the task panel never survives at + // the prompt's expense. for (let rows = 24; rows >= 10; rows--) { const layout = resolveGeometry({ terminal: { columns: 80, rows }, diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 6f956ea57..6ed4ebf01 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -761,9 +761,10 @@ function defaultVisibility(visibility?: ZoneVisibility): ZoneVisibility { notice: false, progress: false, progressDivider: false, - // Explicit 0 rather than left undefined: the agents field is now a row - // count, and setChromeZones compares it by ===, so an undefined start - // forces one needless relayout the first time it is ever compared. + // Explicit 0 rather than left undefined: task and agents are row + // counts, and setChromeZones compares them by ===, so an undefined + // start forces one needless relayout the first time either is compared. + task: 0, agents: 0, ...visibility, } @@ -1895,7 +1896,7 @@ type ShellInternals = { /** Agents panel rows (empty array = zone off), one row per rendered line. */ agents: readonly AgentPanelRow[] } - /** Operator toggle for the task panel; persists for the life of the shell (session). */ + /** Operator toggle for the task panel; in-memory, held for the life of the shell. */ tasksPanelHidden: boolean } @@ -4341,7 +4342,8 @@ export function setChromeZones( * Toggle the task-list panel visible/hidden without touching the live task * data underneath it — un-hiding shows whatever the task tool last wrote, * not a stale snapshot from before the hide. The flag lives on the shell's - * internals for the life of the process, i.e. persists for the session. + * internals in memory for the shell's lifetime; nothing is written to + * storage, so it does not survive a restart. */ export function toggleTasksPanel(shell: AppShell): void { const bag = internals.get(shell) diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index c1ec6c267..848d6dcd9 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -566,7 +566,7 @@ describe("CL-5731: task list panel", () => { ) }) - test("the toggle persists across further chrome pushes for the life of the shell (the session)", async () => { + test("the toggle persists across further chrome pushes for the life of the shell", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, {