Skip to content

Commit 970b0f5

Browse files
committed
Restore the toggleable task-list panel above the prompt box
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.
1 parent 889cd1a commit 970b0f5

9 files changed

Lines changed: 251 additions & 97 deletions

File tree

docs/TUI.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,45 @@ uses the bronze/sand/ember chrome ramp and green (`UI.done`) for completion.
9898
The one deliberate exception is diff removals, where orange is content (the
9999
removed line), not a decision marker, and no decision-marker shares that row.
100100

101+
## The live task list panel
102+
103+
The `task` chrome zone renders a standing panel above the transcript, one row
104+
per task the task tool has written (`manage_tasks`) — distinct from the
105+
`agents` panel below it. A task is a unit of work with a status; an agent is
106+
an executor with its own context and transcript. The two are never merged
107+
into one panel: `formatTasksPanel` (`src/tui-opentui/chrome-state.ts`) and
108+
`formatAgentsPanel` are separate formatters feeding separate zones with
109+
separate row types (`TaskPanelRow` vs. `AgentPanelRow`).
110+
111+
Each row shows a bracket status marker (`[ ]` todo, `[~]` doing, `[x]` done,
112+
`[-]` cancelled) ahead of the title. Terminal tasks still render — the panel
113+
is a live list of work, not just what remains — so an operator watching it
114+
sees a task move to `[x]` rather than have it silently vanish. The panel is
115+
bounded to `TASKS_PANEL_MAX_VISIBLE` rows, same shape as the agents panel: a
116+
longer list degrades to a trailing `+N more` row rather than growing the zone
117+
without limit, and it shrinks one row at a time under space pressure
118+
(`COLLAPSE_ORDER` in `geometry/zones.ts`) rather than vanishing in one step.
119+
`task` sits ahead of `agents` in `COLLAPSE_ORDER`, so on a short terminal the
120+
task panel is always fully collapsed before the prompt box is ever touched —
121+
the prompt is never pushed off screen by a competing chrome zone.
122+
123+
The panel is toggleable independent of its live data: `toggleTasksPanel`
124+
(bound to the `toggle_task` palette action) flips a hidden flag that persists
125+
on the shell for the life of the session, while the live task list keeps
126+
updating underneath it — un-hiding shows the current list, not a stale
127+
snapshot from before the hide. Hidden or empty, the zone costs zero rows.
128+
129+
The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`),
130+
which calls `onTasksChange` on every `manage_tasks` tool call and on session
131+
resume (`restoreTasks`). The runner forwards that into the OpenTUI host via
132+
`RunnerHostDeps.chrome`/`subscribeChrome` (`src/tui-opentui/runner-host.ts`):
133+
`subscribeChrome` is a required dependency, not optional, because an omitted
134+
subscription used to type-check cleanly while silently leaving the panel
135+
frozen at its mount-time snapshot — a mechanism built and never wired, hidden
136+
behind an optional callback. `runner-host.test.ts` drives a live
137+
`subscribeChrome` notify end to end and asserts the panel actually repaints,
138+
so that class of regression fails a test again if it recurs.
139+
101140
## The live agents panel
102141

103142
The `agents` chrome zone renders a standing panel above the transcript, one

src/tui-opentui/chrome-state.ts

Lines changed: 43 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
*/
2424

2525
import { agentProgress, DEFAULT_STALL_MS } from "./agent-progress.js"
26-
import { AGENTS_PANEL_MAX_VISIBLE } from "./geometry/zones.js"
26+
import { AGENTS_PANEL_MAX_VISIBLE, TASKS_PANEL_MAX_VISIBLE } from "./geometry/zones.js"
2727
import type { ChromeZoneContent } from "./shell.js"
2828

2929
/** Subagent row shape for the agents chrome panel (store-agnostic). */
@@ -39,34 +39,33 @@ export type ChromeAgentSession = {
3939
readonly lastActivityAt?: number
4040
}
4141

42-
/**
43-
* Task / Work chrome input.
44-
* Empty title or all-terminal lists → zone hidden when formatting from tasks[].
45-
*/
46-
export type ChromeTaskState = {
47-
/** Current doing (or next todo) title. */
48-
readonly title: string
49-
readonly status?: "todo" | "doing" | "done" | "cancelled"
50-
/** Other active tasks beyond the current one. */
51-
readonly remaining?: number
52-
}
53-
54-
/** Lightweight task row for list → compact line. */
42+
/** Lightweight task row: title + status, as written by the task tool. */
5543
export type ChromeTaskRow = {
5644
readonly title: string
5745
readonly status: "todo" | "doing" | "done" | "cancelled"
5846
}
5947

48+
/**
49+
* One rendered task-panel row. `status` is null for a non-task row (the
50+
* "+N more" trailer, or a bare-string task input with no structured status)
51+
* so the renderer knows not to paint a status marker for it.
52+
*/
53+
export type TaskPanelRow = {
54+
readonly label: string
55+
readonly status: "todo" | "doing" | "done" | "cancelled" | null
56+
}
57+
6058
/**
6159
* Full live chrome snapshot. Missing / null fields hide that zone.
6260
* Prefer pushing a complete snapshot on every update.
6361
*/
6462
export type ChromeLiveState = {
6563
/**
66-
* Compact task line: string shorthand, structured current task, or a list
67-
* of work rows (formatter picks the active item like Ink TaskView compact).
64+
* Task list: string shorthand (rendered as a single unstyled row) or the
65+
* structured rows the task tool writes. Distinct from `agents` — a task is
66+
* a unit of work with a status, not an executor.
6867
*/
69-
readonly task?: ChromeTaskState | ChromeTaskRow[] | string | null
68+
readonly task?: readonly ChromeTaskRow[] | string | null
7069
/** Subagent sessions for the strip summary (running preferred). */
7170
readonly agents?: readonly ChromeAgentSession[] | null
7271
/**
@@ -94,13 +93,14 @@ export type AgentPanelRow = {
9493

9594
/** Always-populated result for setChromeZones (null = hide zone). */
9695
export type FormattedChromeZones = {
97-
readonly task: string | null
96+
/** One row per rendered task-panel line (null = hide zone, zero rows). */
97+
readonly task: readonly TaskPanelRow[] | null
9898
/** One row per rendered agents-panel line (null = hide zone, zero rows). */
9999
readonly agents: readonly AgentPanelRow[] | null
100100
}
101101

102102
/**
103-
* Format structured live state into chrome zone lines for setChromeZones.
103+
* Format structured live state into chrome zone rows for setChromeZones.
104104
*
105105
* Empty / partial / inactive inputs yield null for the corresponding zone
106106
* so geometry collapses that strip (idleDefault 0).
@@ -110,7 +110,7 @@ export function formatChromeZones(
110110
nowMs: number = Date.now(),
111111
): FormattedChromeZones {
112112
return {
113-
task: formatTaskLine(state.task),
113+
task: formatTasksPanel(state.task),
114114
agents: formatAgentsPanel(state.agents, state.observe, nowMs),
115115
}
116116
}
@@ -124,43 +124,36 @@ export function chromeZonesContent(state: ChromeLiveState): ChromeZoneContent {
124124
return formatChromeZones(state)
125125
}
126126

127-
export function formatTaskLine(
128-
task: ChromeTaskState | ChromeTaskRow[] | string | null | undefined,
129-
): string | null {
127+
/**
128+
* Format the live task-list panel: one row per task, bounded to `maxVisible`
129+
* with a trailing "+N more" row, mirroring `formatAgentsPanel`'s shape but
130+
* keyed on status (not liveness) since a task has no clock of its own.
131+
*
132+
* Terminal tasks (done/cancelled) still render — the panel is a live list of
133+
* work, not just what remains — so an operator watching it sees a task move
134+
* to "done" rather than silently vanish. A bare string input renders as one
135+
* row with no status marker: it is free-form summary text, not a task record.
136+
*/
137+
export function formatTasksPanel(
138+
task: readonly ChromeTaskRow[] | string | null | undefined,
139+
maxVisible: number = TASKS_PANEL_MAX_VISIBLE,
140+
): readonly TaskPanelRow[] | null {
130141
if (task === null || task === undefined) return null
131142

132143
if (typeof task === "string") {
133144
const t = task.trim()
134-
return t.length === 0 ? null : compactLine("task", t)
135-
}
136-
137-
if (Array.isArray(task)) {
138-
return formatTaskLineFromRows(task)
145+
return t.length === 0 ? null : [{ label: t, status: null }]
139146
}
140147

141-
const title = task.title.trim()
142-
if (title.length === 0) return null
143-
if (task.status === "done" || task.status === "cancelled") return null
148+
const rows: TaskPanelRow[] = task
149+
.map((t) => ({ label: t.title.trim(), status: t.status }))
150+
.filter((r) => r.label.length > 0)
151+
if (rows.length === 0) return null
144152

145-
const remaining =
146-
task.remaining !== undefined && task.remaining > 0
147-
? ` (+${task.remaining})`
148-
: ""
149-
return compactLine("task", `${title}${remaining}`)
150-
}
151-
152-
function formatTaskLineFromRows(rows: readonly ChromeTaskRow[]): string | null {
153-
const active = rows.filter(
154-
(t) => t.status !== "done" && t.status !== "cancelled",
155-
)
156-
if (active.length === 0) return null
157-
const doing = active.find((t) => t.status === "doing")
158-
const current = doing ?? active[0]!
159-
const title = current.title.trim()
160-
if (title.length === 0) return null
161-
const remaining = active.length - 1
162-
const suffix = remaining > 0 ? ` (+${remaining})` : ""
163-
return compactLine("task", `${title}${suffix}`)
153+
const visible = rows.slice(0, maxVisible)
154+
const hidden = rows.length - visible.length
155+
if (hidden > 0) visible.push({ label: `+${hidden} more`, status: null })
156+
return visible
164157
}
165158

166159
/**
@@ -274,14 +267,6 @@ export function annotateAgentTools(
274267
}
275268
}
276269

277-
function compactLine(prefix: string, body: string): string {
278-
const b = body.trim()
279-
if (b.length === 0) return `${prefix}:`
280-
// Avoid double-prefix if host already included it.
281-
if (b.toLowerCase().startsWith(`${prefix}:`)) return b
282-
return `${prefix}: ${b}`
283-
}
284-
285270
// ---------------------------------------------------------------------------
286271
// Session-shaped → ChromeLiveState (loose mapping for product host push)
287272
// ---------------------------------------------------------------------------

src/tui-opentui/geometry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export {
1111
PROMPT_CAP_FRACTION,
1212
PROMPT_IDLE_INPUT_ROWS,
1313
PROMPT_IDLE_ROWS,
14+
TASKS_PANEL_MAX_VISIBLE,
1415
ZONE_IDS,
1516
ZONE_REGISTRY,
1617
zoneDeclaration,

src/tui-opentui/geometry/resolve.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export type ZoneVisibility = {
4646
readonly progress?: boolean | 1 | 2;
4747
/** Progress divider (0–1). Default on when progress is shown. */
4848
readonly progressDivider?: boolean;
49-
readonly task?: boolean;
49+
/** Task panel: false/omit = 0 rows; true = 1 row; or an exact row count (bounded by the zone max). */
50+
readonly task?: boolean | number;
5051
/** Agents panel: false/omit = 0 rows; true = 1 row; or an exact row count (bounded by the zone max). */
5152
readonly agents?: boolean | number;
5253
readonly pluginBanner?: boolean;
@@ -139,7 +140,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
139140
progress_divider: progressDivider,
140141
notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault,
141142
prompt: promptRows,
142-
task: vis.task ? 1 : 0,
143+
task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max),
143144
agents: clamp(boolOrRows(vis.agents, 1), 0, ZONE_REGISTRY.agents.max),
144145
plugin_banner: vis.pluginBanner ? 1 : 0,
145146
command_banner: clamp(
@@ -236,6 +237,24 @@ function collapseOnce(heights: MutableHeights, collapsed: ZoneId[]): ZoneId | nu
236237
return "progress";
237238
}
238239

240+
if (id === "task") {
241+
// Shrink one row at a time rather than zeroing in one step, same
242+
// rationale as "agents" below: a 1-row panel still carries the first
243+
// task plus a "+N more" trailer, so it stays meaningful all the way
244+
// down instead of vanishing under exactly the pressure an operator
245+
// most needs to see it. This is also what keeps the task panel
246+
// degrading before the prompt box: it sits ahead of "agents" and
247+
// every other optional zone in COLLAPSE_ORDER.
248+
if (h > 1) {
249+
heights.task = h - 1;
250+
if (!collapsed.includes("task")) collapsed.push("task");
251+
return "task";
252+
}
253+
heights.task = 0;
254+
if (!collapsed.includes("task")) collapsed.push("task");
255+
return "task";
256+
}
257+
239258
if (id === "agents") {
240259
// Shrink one row at a time rather than zeroing in one step: a 1-row
241260
// panel still carries the stalest agent plus a "+N more" trailer

src/tui-opentui/geometry/zones.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export type ZoneDeclaration = {
4141
*/
4242
export const AGENTS_PANEL_MAX_VISIBLE = 5;
4343

44+
/**
45+
* Bound on rendered task rows in the live task-list panel. Mirrors
46+
* AGENTS_PANEL_MAX_VISIBLE: a large task list degrades to a trailing
47+
* "+N more" row instead of growing the zone without limit.
48+
*/
49+
export const TASKS_PANEL_MAX_VISIBLE = 5;
50+
4451
/**
4552
* Fixed-with-test budgets from the constitution table.
4653
* 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 } = {
6774
idleDefault: 5,
6875
alwaysOn: true,
6976
},
70-
task: { id: "task", min: 0, max: 1, idleDefault: 0, alwaysOn: false },
77+
// One row per task (bounded by TASKS_PANEL_MAX_VISIBLE) plus an optional
78+
// trailing "+N more" row. Distinct panel from `agents`: a task is a unit
79+
// of work with a status, not an executor.
80+
task: {
81+
id: "task",
82+
min: 0,
83+
max: TASKS_PANEL_MAX_VISIBLE + 1,
84+
idleDefault: 0,
85+
alwaysOn: false,
86+
},
7187
// One row per running agent (bounded by AGENTS_PANEL_MAX_VISIBLE) plus an
7288
// optional trailing "+N more" row.
7389
agents: {

src/tui-opentui/keybindings.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ describe("the runner host does not shadow the prompt bindings the catalog claims
656656
commands: [],
657657
onCommand: () => {},
658658
chrome: () => ({ agents: [] }),
659+
subscribeChrome: () => () => {},
659660
subAgentSessions: () => [],
660661
createRenderer: async () => harness.renderer,
661662
})

src/tui-opentui/palette.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ export const DEFAULT_PALETTE_COMMANDS: readonly PaletteCommand[] = [
8888
},
8989
{
9090
id: "toggle_task",
91-
label: "Toggle task chrome",
92-
keywords: ["task", "work", "chrome"],
91+
label: "Toggle task list panel",
92+
keywords: ["task", "work", "chrome", "list", "panel"],
9393
dispatch: "residual",
9494
},
9595
{

src/tui-opentui/runner-host.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,14 @@ export type RunnerHostDeps = {
106106
readonly onCommand: (name: string) => void
107107
/** Live chrome snapshot source, read on mount and on every notify. */
108108
readonly chrome: () => ChromeSessionInput
109-
/** Registers a chrome-change notifier; returns an unsubscribe. */
110-
readonly subscribeChrome?: (notify: () => void) => () => void
109+
/**
110+
* Registers a chrome-change notifier; returns an unsubscribe. Required, not
111+
* optional: an omitted subscription used to type-check cleanly while
112+
* silently leaving the task/agents panels frozen at their mount-time
113+
* snapshot — the exact "mechanism built, never wired" shape this signature
114+
* now makes impossible to omit by accident.
115+
*/
116+
readonly subscribeChrome: (notify: () => void) => () => void
111117
/** Live subagent sessions for the palette observe action. */
112118
readonly subAgentSessions: () => readonly SubAgentSession[]
113119
/**
@@ -278,7 +284,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
278284
const pushChrome = (): void => {
279285
host.setChrome(chromeFromSession(deps.chrome()))
280286
}
281-
const unsubscribeChrome = deps.subscribeChrome?.(pushChrome)
287+
const unsubscribeChrome = deps.subscribeChrome(pushChrome)
282288

283289
if (readModelLabel) setPromptModelLabel(host.shell, readModelLabel())
284290

0 commit comments

Comments
 (0)