Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,58 @@ 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.

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 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. 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

The `agents` chrome zone renders a standing panel above the transcript, one
Expand Down
92 changes: 49 additions & 43 deletions src/tui-opentui/chrome-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
chromeFromSession,
formatAgentsPanel,
formatChromeZones,
formatTaskLine,
formatTasksPanel,
type ChromeLiveState,
} from "./chrome-state"

Expand All @@ -28,19 +28,21 @@ describe("formatChromeZones", () => {
})
})

test("partial: task string only", () => {
const out = formatChromeZones({ task: "cutover readiness" })
expect(out.task).toBe("task: cutover readiness")
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()
})

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",
Expand All @@ -58,7 +60,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",
Expand Down Expand Up @@ -95,53 +101,50 @@ describe("formatChromeZones", () => {
})
})

describe("formatTaskLine", () => {
test("string / empty", () => {
expect(formatTaskLine(null)).toBeNull()
expect(formatTaskLine("")).toBeNull()
expect(formatTaskLine(" ")).toBeNull()
expect(formatTaskLine("wire host")).toBe("task: wire host")
describe("formatTasksPanel", () => {
test("null / undefined hide the zone", () => {
expect(formatTasksPanel(null)).toBeNull()
expect(formatTasksPanel(undefined)).toBeNull()
})

test("structured with remaining", () => {
test("each row carries its own status", () => {
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()
})

test("rows pick doing and remaining", () => {
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 })
})
})

Expand Down Expand Up @@ -300,7 +303,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 },
])
Expand Down
102 changes: 40 additions & 62 deletions src/tui-opentui/chrome-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -39,34 +39,32 @@ 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: 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[] | null
/** Subagent sessions for the strip summary (running preferred). */
readonly agents?: readonly ChromeAgentSession[] | null
/**
Expand Down Expand Up @@ -94,13 +92,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).
Expand All @@ -110,7 +109,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),
}
}
Expand All @@ -124,43 +123,30 @@ 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.
*/
export function formatTasksPanel(
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 : compactLine("task", t)
}

if (Array.isArray(task)) {
return formatTaskLineFromRows(task)
}

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
}

/**
Expand Down Expand Up @@ -274,14 +260,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)
// ---------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion src/tui-opentui/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading