@kolisachint/hoocode-agent 0.4.113 → 0.4.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +1 -1
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/extensions/plugins/authoring.d.ts +51 -0
- package/dist/core/extensions/plugins/authoring.d.ts.map +1 -0
- package/dist/core/extensions/plugins/authoring.js +91 -0
- package/dist/core/extensions/plugins/authoring.js.map +1 -0
- package/dist/core/extensions/plugins/default-marketplace/.agents-plugin/marketplace.json +12 -0
- package/dist/core/extensions/plugins/default-marketplace/plugins/hello-world/.agents-plugin/plugin.json +6 -0
- package/dist/core/extensions/plugins/default-marketplace/plugins/hello-world/commands/hello.md +5 -0
- package/dist/core/extensions/plugins/default-marketplace/plugins/hello-world/skills/greet/SKILL.md +7 -0
- package/dist/core/extensions/plugins/formats/agents.d.ts +7 -0
- package/dist/core/extensions/plugins/formats/agents.d.ts.map +1 -0
- package/dist/core/extensions/plugins/formats/agents.js +14 -0
- package/dist/core/extensions/plugins/formats/agents.js.map +1 -0
- package/dist/core/extensions/plugins/formats/claude.d.ts +10 -0
- package/dist/core/extensions/plugins/formats/claude.d.ts.map +1 -0
- package/dist/core/extensions/plugins/formats/claude.js +17 -0
- package/dist/core/extensions/plugins/formats/claude.js.map +1 -0
- package/dist/core/extensions/plugins/formats/copilot.d.ts +23 -0
- package/dist/core/extensions/plugins/formats/copilot.d.ts.map +1 -0
- package/dist/core/extensions/plugins/formats/copilot.js +130 -0
- package/dist/core/extensions/plugins/formats/copilot.js.map +1 -0
- package/dist/core/extensions/plugins/formats/index.d.ts +41 -0
- package/dist/core/extensions/plugins/formats/index.d.ts.map +1 -0
- package/dist/core/extensions/plugins/formats/index.js +76 -0
- package/dist/core/extensions/plugins/formats/index.js.map +1 -0
- package/dist/core/extensions/plugins/formats/jsonManifest.d.ts +21 -0
- package/dist/core/extensions/plugins/formats/jsonManifest.d.ts.map +1 -0
- package/dist/core/extensions/plugins/formats/jsonManifest.js +97 -0
- package/dist/core/extensions/plugins/formats/jsonManifest.js.map +1 -0
- package/dist/core/extensions/plugins/formats/shared.d.ts +44 -0
- package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -0
- package/dist/core/extensions/plugins/formats/shared.js +107 -0
- package/dist/core/extensions/plugins/formats/shared.js.map +1 -0
- package/dist/core/extensions/plugins/formats/types.d.ts +118 -0
- package/dist/core/extensions/plugins/formats/types.d.ts.map +1 -0
- package/dist/core/extensions/plugins/formats/types.js +19 -0
- package/dist/core/extensions/plugins/formats/types.js.map +1 -0
- package/dist/core/extensions/plugins/install.d.ts +73 -0
- package/dist/core/extensions/plugins/install.d.ts.map +1 -0
- package/dist/core/extensions/plugins/install.js +173 -0
- package/dist/core/extensions/plugins/install.js.map +1 -0
- package/dist/core/extensions/plugins/manifest.d.ts +25 -7
- package/dist/core/extensions/plugins/manifest.d.ts.map +1 -1
- package/dist/core/extensions/plugins/manifest.js +16 -91
- package/dist/core/extensions/plugins/manifest.js.map +1 -1
- package/dist/core/extensions/plugins/marketplace.d.ts +5 -4
- package/dist/core/extensions/plugins/marketplace.d.ts.map +1 -1
- package/dist/core/extensions/plugins/marketplace.js +8 -13
- package/dist/core/extensions/plugins/marketplace.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +2 -0
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +2 -0
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +4 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +16 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +2 -0
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/core/subagent-depth.d.ts +13 -0
- package/dist/core/subagent-depth.d.ts.map +1 -1
- package/dist/core/subagent-depth.js +15 -0
- package/dist/core/subagent-depth.js.map +1 -1
- package/dist/core/subagent-pool.d.ts.map +1 -1
- package/dist/core/subagent-pool.js +4 -1
- package/dist/core/subagent-pool.js.map +1 -1
- package/dist/core/tools/find.d.ts +8 -3
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +189 -122
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/index.d.ts +0 -4
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +2 -5
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/plugin-tool-names.d.ts +21 -0
- package/dist/core/tools/plugin-tool-names.d.ts.map +1 -0
- package/dist/core/tools/plugin-tool-names.js +31 -0
- package/dist/core/tools/plugin-tool-names.js.map +1 -0
- package/dist/core/tools/plugins.d.ts +49 -0
- package/dist/core/tools/plugins.d.ts.map +1 -0
- package/dist/core/tools/plugins.js +186 -0
- package/dist/core/tools/plugins.js.map +1 -0
- package/dist/core/tools/propose-plugin.d.ts +36 -0
- package/dist/core/tools/propose-plugin.d.ts.map +1 -0
- package/dist/core/tools/propose-plugin.js +219 -0
- package/dist/core/tools/propose-plugin.js.map +1 -0
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +29 -3
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/core/warm-subagent-pool.d.ts.map +1 -1
- package/dist/core/warm-subagent-pool.js +4 -1
- package/dist/core/warm-subagent-pool.js.map +1 -1
- package/dist/extensions/core/marketplace.d.ts +7 -3
- package/dist/extensions/core/marketplace.d.ts.map +1 -1
- package/dist/extensions/core/marketplace.js +23 -67
- package/dist/extensions/core/marketplace.js.map +1 -1
- package/dist/extensions/core/mcp-deferred.d.ts +28 -0
- package/dist/extensions/core/mcp-deferred.d.ts.map +1 -0
- package/dist/extensions/core/mcp-deferred.js +53 -0
- package/dist/extensions/core/mcp-deferred.js.map +1 -0
- package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
- package/dist/extensions/core/mcp-loader.js +132 -78
- package/dist/extensions/core/mcp-loader.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +19 -1
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/brand.d.ts +41 -0
- package/dist/modes/interactive/brand.d.ts.map +1 -0
- package/dist/modes/interactive/brand.js +40 -0
- package/dist/modes/interactive/brand.js.map +1 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +102 -131
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts +6 -5
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/task-panel.js +13 -9
- package/dist/modes/interactive/components/task-panel.js.map +1 -1
- package/dist/modes/interactive/resource-display.d.ts.map +1 -1
- package/dist/modes/interactive/resource-display.js +131 -86
- package/dist/modes/interactive/resource-display.js.map +1 -1
- package/dist/modes/interactive/theme/dark.json +1 -1
- package/dist/modes/interactive/theme/light.json +1 -1
- package/dist/modes/interactive/voice/voice-controller.d.ts.map +1 -1
- package/dist/modes/interactive/voice/voice-controller.js +15 -7
- package/dist/modes/interactive/voice/voice-controller.js.map +1 -1
- package/dist/modes/interactive/voice/voice-panel.d.ts +27 -8
- package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
- package/dist/modes/interactive/voice/voice-panel.js +89 -27
- package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +8 -2
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +15 -6
- package/dist/utils/tools-manager.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/minimal-mode.ts +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +5 -5
- package/dist/core/tools/glob.d.ts +0 -42
- package/dist/core/tools/glob.d.ts.map +0 -1
- package/dist/core/tools/glob.js +0 -432
- package/dist/core/tools/glob.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/task-panel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAsD1E;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;AAooB3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,qBAAa,kBAAmB,YAAW,SAAS,EAAE,SAAS;IAC9D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAa;IAChC,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,IAAI,CAAyB;IACrC,OAAO,CAAC,QAAQ,CAAS;IAQzB,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,QAAQ,CAA6B;IAC7C,OAAO,CAAC,aAAa,CAAuC;IAC5D,OAAO,CAAC,gBAAgB,CAAkC;IAC1D,OAAO,CAAC,YAAY,CAAkC;IAEtD,sEAAsE;IACtE,OAAO,CAAC,QAAQ;IAWhB,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,QAAQ;IAOhB;;;;;OAKG;IACH,OAAO,CAAC,QAAQ;IAmBhB,OAAO,UAAS;IAChB,OAAO,CAAC,YAAY,CAAqB;IACzC,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB,YAAY,EAAE,CAAC,EAAE,GAAG,EAEnB;IAED,UAAU,IAAI,IAAI,CAGjB;IAED,OAAO,CAAC,UAAU;IAIlB,2EAA2E;IAC3E,WAAW,IAAI,MAAM,GAAG,SAAS,CAKhC;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAyB9B;IAED,OAAO,IAAI,aAAa,CAEvB;IAED,OAAO,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAGjC;IAED;;;;OAIG;IACH,SAAS,IAAI,aAAa,CAMzB;IAED,6EAA6E;IAC7E,OAAO,CAAC,eAAe;IAsBvB,gDAAgD;IAChD,OAAO,IAAI,IAAI,CAMd;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA6I9B;CACD","sourcesContent":["import type { Component, Focusable, TUI } from \"@kolisachint/hoocode-tui\";\nimport { getKeybindings, matchesKey, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { formatDurationSecs } from \"../../../core/format-duration.js\";\nimport type { Task, TaskAgent, TaskAgentKind, TaskAgentState, TaskStatus } from \"../../../core/task-store.js\";\nimport { taskOwnerId, taskStore } from \"../../../core/task-store.js\";\nimport type { ThemeColor } from \"../theme/theme.js\";\nimport { agentColorFor, theme } from \"../theme/theme.js\";\nimport { appKeyLabel, matchesAppKey, rawKeyHint } from \"./keybinding-hints.js\";\n\nconst TASK_STATUS_ICON: Record<TaskStatus, string> = {\n\t// Hollow = not started, matching the hollow-means-lighter logic of ◇ and the\n\t// selectors' ○/◉ convention; also keeps ● exclusive to the chat's tool\n\t// status dot so the two never read as the same signal.\n\tpending: \"○\",\n\tin_progress: \"◐\",\n\tdone: \"✓\",\n\tfailed: \"✗\",\n\tcancelled: \"⊘\",\n};\n\n/**\n * U+26A0 with VS15 (text presentation). Bare ⚠ carries the Unicode Emoji\n * property, and terminals with emoji font fallback render it as a double-width\n * color glyph while the width math counts 1 cell — misaligning exactly the\n * rows that carry a warning. VS15 forces single-cell text rendering and is\n * itself zero-width, so visibleWidth stays correct.\n */\nconst WARNING_GLYPH = \"⚠︎\";\n\n/** U+25B6 with VS15 — same emoji-presentation hazard as WARNING_GLYPH. */\nconst SELECTED_GLYPH = \"▶︎\";\n\n/**\n * Single-cell marker for MCP-sourced rows, which have no owning agent. Every\n * other row derives its marker from the owner's kind via AGENT_GLYPH (◆ main /\n * ◇ subagent / ▸ team role), so the flat lens attributes a row exactly the way\n * the grouped lenses do. The row also carries a text origin tag before the\n * title (see formatTaskLine).\n */\nconst MCP_SOURCE_GLYPH = \"⧉\";\n\n/** Braille spinner frames + cadence, matched to the TUI Loader so the active\n * row animates in step. Every tick re-renders the whole component tree, so the\n * cadence is a direct tax on long sessions — 120ms stays smooth while cutting\n * a third of the render load the old 80ms cadence generated. */\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nconst SPINNER_INTERVAL_MS = 120;\n\n/** A thin colored left rail groups the pane without a box, the way the design's `border-left` does. */\nconst RAIL = \"▎\";\n\n/** Cells in the deterministic progress bar (matches the design's 14-cell track). */\nconst PROGRESS_CELLS = 14;\n\n/**\n * How the same task list is presented:\n * - flat → one ungrouped list (default)\n * - subagents → grouped by owning agent (◆ main orchestrator + ◇ workers)\n * - teams → grouped by named role-agent (▸), with handoff arrows\n */\nexport type TaskPanelView = \"flat\" | \"subagents\" | \"teams\";\n\nconst VIEW_LABEL: Record<TaskPanelView, string> = { flat: \"tasks\", subagents: \"subagents\", teams: \"teams\" };\n\n/**\n * A top-level task the main agent owns directly: its own TodoWrite plan. Source\n * is unset (not a subagent/MCP delegation) and it sits at the root of the forest\n * (not a child merged in from a subagent's subtree). These are exactly the rows\n * the flat (\"tasks\") lens shows.\n */\nfunction isMainTask(task: Task): boolean {\n\t// Source-unset (not a subagent/MCP delegation — note taskOwnerId folds MCP into\n\t// \"main\", so check source directly), not owned by a role agent, and a forest\n\t// root (not a child merged in from a subagent's subtree).\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/**\n * Lenses that currently have content, split by ownership:\n * - flat (\"tasks\") → only when the main agent has its own TodoWrite plan.\n * - subagents → when delegated work exists (a registered subagent, or any\n * subagent/MCP-sourced task).\n * - teams → when role agents are registered (hooteams `--team`).\n * The cycle key and the header switcher both skip empty lenses, and an empty\n * flat lens falls through to subagents (see render), so a session that only\n * delegated work opens straight on the task tree with no empty \"tasks\" view.\n */\nfunction availableViews(tasks: readonly Task[], agents: readonly TaskAgent[]): TaskPanelView[] {\n\tconst views: TaskPanelView[] = [];\n\tif (tasks.some(isMainTask)) views.push(\"flat\");\n\tconst hasSubagentWork =\n\t\tagents.some((a) => a.kind === \"subagent\") || tasks.some((t) => t.source === \"subagent\" || t.source === \"mcp\");\n\tif (hasSubagentWork) views.push(\"subagents\");\n\tif (agents.some((a) => a.kind === \"role\")) views.push(\"teams\");\n\treturn views;\n}\n\n/** Owner glyphs: main agent a filled diamond, spawned subagents the hollow counterpart, team roles a triangle. */\nconst AGENT_GLYPH: Record<TaskAgentKind, string> = { main: \"◆\", subagent: \"◇\", role: \"▸\" };\nconst AGENT_GLYPH_COLOR: Record<TaskAgentKind, ThemeColor> = {\n\tmain: \"accent\",\n\tsubagent: \"accent\",\n\trole: \"borderAccent\",\n};\n\n/** Color for an agent's lifecycle `[state]` tag (mirrors the design's .ast-* classes). */\nconst AGENT_STATE_COLOR: Record<TaskAgentState, ThemeColor> = {\n\tactive: \"warning\",\n\trunning: \"warning\",\n\tdone: \"success\",\n\tqueued: \"dim\",\n\tidle: \"dim\",\n\twaiting: \"mdLink\",\n\tfailed: \"error\",\n\t// User-initiated stop: settled and intentional, so muted rather than red.\n\tcancelled: \"dim\",\n};\n\n/** Two-cell indent under a group header, with a faint vertical guide. */\nconst GROUP_INDENT_PLAIN = \"│ \";\n\n/** Overall pane state, derived from the task statuses. Drives the rail color + header stamp. */\ntype PanelState = \"working\" | \"reviewed\" | \"stopped\";\n\ninterface StatePresentation {\n\treadonly icon: string;\n\treadonly label: string;\n\treadonly color: \"warning\" | \"success\" | \"error\";\n}\n\nconst STATE_PRESENTATION: Record<PanelState, StatePresentation> = {\n\tworking: { icon: \"◐\", label: \"working\", color: \"warning\" },\n\treviewed: { icon: \"✓\", label: \"reviewed\", color: \"success\" },\n\tstopped: { icon: \"✗\", label: \"stopped\", color: \"error\" },\n};\n\nfunction panelState(tasks: readonly Task[]): PanelState {\n\tif (tasks.some((t) => t.status === \"failed\")) return \"stopped\";\n\tconst active = tasks.some((t) => t.status === \"in_progress\" || t.status === \"pending\");\n\treturn active ? \"working\" : \"reviewed\";\n}\n\nfunction taskStatusColor(status: TaskStatus): \"dim\" | \"warning\" | \"success\" | \"error\" {\n\tswitch (status) {\n\t\tcase \"in_progress\":\n\t\t\treturn \"warning\";\n\t\tcase \"done\":\n\t\t\treturn \"success\";\n\t\tcase \"failed\":\n\t\t\treturn \"error\";\n\t\tdefault:\n\t\t\t// pending and cancelled: quiet gray — a user-initiated cancel is not an\n\t\t\t// error and must not paint the row (or the rail) red.\n\t\t\treturn \"dim\";\n\t}\n}\n\n/**\n * Wall-clock time a task occupied. A settled task reads its create→final-update\n * span; a task still in progress is measured against the clock so its elapsed\n * time advances while it runs (updatedAt barely moves during a run, which used\n * to freeze the display at ~0s until completion).\n */\nfunction taskElapsedSecs(task: Task, now: number = Date.now()): number {\n\tconst end = task.status === \"in_progress\" ? now : task.updatedAt;\n\treturn Math.max(0, (end - task.createdAt) / 1000);\n}\n\n/**\n * Wall-clock span of the whole visible batch: earliest creation → now (while\n * anything is still running) or the latest settle time. NOT the sum of\n * per-task spans — summing made the header clock tick at 2× with two\n * concurrent subagents and 3× with three, a nonsense \"double time\" display.\n * Per-run timers live on the individual rows.\n */\nfunction turnElapsedSecs(tasks: readonly Task[], now: number = Date.now()): number {\n\tif (tasks.length === 0) return 0;\n\tlet earliest = Number.POSITIVE_INFINITY;\n\tlet latestEnd = 0;\n\tlet anyRunning = false;\n\tfor (const task of tasks) {\n\t\tearliest = Math.min(earliest, task.createdAt);\n\t\tif (task.status === \"in_progress\") anyRunning = true;\n\t\telse latestEnd = Math.max(latestEnd, task.updatedAt);\n\t}\n\tconst end = anyRunning ? now : latestEnd;\n\treturn Math.max(0, (end - earliest) / 1000);\n}\n\n/** The agent-type key a roster entry colors by: \"explore#2\" → \"explore\". */\nfunction agentTypeOfName(name: string): string {\n\tconst idx = name.indexOf(\"#\");\n\treturn idx > 0 ? name.slice(0, idx) : name;\n}\n\n/** Sum the token + cost usage reported by the tasks shown this turn. */\nfunction sumTurnUsage(tasks: readonly Task[]): { input: number; output: number; cost: number } | null {\n\tlet input = 0;\n\tlet output = 0;\n\tlet cost = 0;\n\tfor (const task of tasks) {\n\t\tif (!task.usage) continue;\n\t\tinput += task.usage.input;\n\t\toutput += task.usage.output;\n\t\tcost += task.usage.cost;\n\t}\n\tif (input === 0 && output === 0 && cost === 0) return null;\n\treturn { input, output, cost };\n}\n\n/**\n * Deterministic block-glyph progress bar: a heavy run (━) for the completed\n * fraction over a dim track. In-progress tasks count as half, so the bar moves\n * the moment work starts. Fraction is the only input — no animation, no guess.\n */\nfunction progressBar(done: number, active: number, total: number): { plain: string; styled: string } {\n\tconst ratio = total > 0 ? Math.max(0, Math.min(1, (done + active * 0.5) / total)) : 0;\n\tconst filled = Math.round(ratio * PROGRESS_CELLS);\n\tconst fill = \"━\".repeat(filled);\n\tconst track = \"━\".repeat(PROGRESS_CELLS - filled);\n\treturn {\n\t\tplain: fill + track,\n\t\tstyled: theme.fg(\"success\", fill) + theme.fg(\"dim\", track),\n\t};\n}\n\n/**\n * View switcher rendered at the right edge of the ledger header: the labels\n * of the lenses that have content joined by `·`, the active one in bold\n * accent. Hidden entirely when only one lens is available. Purely an\n * indicator in the TUI — the bound key cycles it (see app.tasks.cycleView).\n */\nfunction formatViewSwitcher(\n\tview: TaskPanelView,\n\tavailable: readonly TaskPanelView[],\n): { plain: string; styled: string } {\n\tif (available.length < 2) return { plain: \"\", styled: \"\" };\n\tconst plain = available.map((v) => VIEW_LABEL[v]).join(\" · \");\n\tconst styled = available\n\t\t.map((v) => (v === view ? theme.bold(theme.fg(\"accent\", VIEW_LABEL[v])) : theme.fg(\"dim\", VIEW_LABEL[v])))\n\t\t.join(theme.fg(\"dim\", \" · \"));\n\treturn { plain, styled };\n}\n\n/**\n * Ledger header: a state stamp (◐ working / ✓ reviewed / ✗ stopped) + a\n * deterministic progress bar and done/total count on the left, and the per-turn\n * token + elapsed + cost delta (summed across the tasks below) plus the view\n * switcher on the right.\n */\nfunction formatHeader(\n\ttasks: readonly Task[],\n\twidth: number,\n\tstate: PanelState,\n\ttotalSecs: number,\n\tview: TaskPanelView,\n\tavailable: readonly TaskPanelView[],\n): string {\n\tconst total = tasks.length;\n\tconst done = tasks.filter((t) => t.status === \"done\").length;\n\tconst active = tasks.filter((t) => t.status === \"in_progress\").length;\n\n\tconst { icon, label, color } = STATE_PRESENTATION[state];\n\tconst stampPlain = `${icon} ${label.toUpperCase()}`;\n\tconst stamp = `${theme.fg(color, icon)} ${theme.bold(theme.fg(color, label.toUpperCase()))}`;\n\n\tconst bar = progressBar(done, active, total);\n\tconst countPlain = `${done}/${total}`;\n\tconst count = theme.fg(\"muted\", `${done}`) + theme.fg(\"dim\", \"/\") + theme.fg(\"muted\", `${total}`);\n\n\t// Left cluster has a full form (stamp · bar · count) and a compact fallback\n\t// (stamp · count) that drops the bar when the terminal is too narrow.\n\tconst leftFullPlain = `${stampPlain} ${bar.plain} ${countPlain}`;\n\tconst leftFull = `${stamp} ${bar.styled} ${count}`;\n\tconst leftMinPlain = `${stampPlain} ${countPlain}`;\n\tconst leftMin = `${stamp} ${count}`;\n\n\tconst turn = sumTurnUsage(tasks);\n\tlet turnPlain = \"\";\n\tlet turnText = \"\";\n\tif (turn) {\n\t\tconst inTok = formatTokens(turn.input);\n\t\tconst outTok = formatTokens(turn.output);\n\t\tconst elapsed = formatDurationSecs(totalSecs);\n\t\tconst showCost = turn.cost > 0;\n\t\tconst costStr = showCost ? `$${turn.cost.toFixed(3)}` : \"\";\n\t\tturnPlain = `turn ↑${inTok} ↓${outTok} · ${elapsed}${showCost ? ` · ${costStr}` : \"\"}`;\n\t\t// Turn delta: muted framing, numbers one step brighter (bold), separators dim.\n\t\tturnText =\n\t\t\ttheme.fg(\"muted\", \"turn ↑\") +\n\t\t\ttheme.bold(inTok) +\n\t\t\ttheme.fg(\"muted\", \" ↓\") +\n\t\t\ttheme.bold(outTok) +\n\t\t\ttheme.fg(\"dim\", \" · \") +\n\t\t\ttheme.fg(\"muted\", elapsed) +\n\t\t\t(showCost ? theme.fg(\"dim\", \" · \") + theme.bold(costStr) : \"\");\n\t}\n\n\t// Right cluster: turn delta, then the view switcher at the far edge. The\n\t// switcher is the first thing dropped when the terminal narrows; the turn\n\t// delta next; the stamp/count survive to the end. Either piece may be\n\t// absent (no usage reported / only one lens available).\n\tconst switcher = formatViewSwitcher(view, available);\n\tconst rightVariants: Array<{ plain: string; styled: string }> = [];\n\tif (turnPlain && switcher.plain) {\n\t\trightVariants.push({\n\t\t\tplain: `${turnPlain} ${switcher.plain}`,\n\t\t\tstyled: `${turnText} ${switcher.styled}`,\n\t\t});\n\t}\n\tif (turnPlain) rightVariants.push({ plain: turnPlain, styled: turnText });\n\telse if (switcher.plain) rightVariants.push(switcher);\n\n\tfor (const right of rightVariants) {\n\t\tif (visibleWidth(leftFullPlain) + 2 + visibleWidth(right.plain) <= width) {\n\t\t\tconst pad = Math.max(2, width - visibleWidth(leftFullPlain) - visibleWidth(right.plain));\n\t\t\treturn leftFull + \" \".repeat(pad) + right.styled;\n\t\t}\n\t\tif (visibleWidth(leftMinPlain) + 2 + visibleWidth(right.plain) <= width) {\n\t\t\tconst pad = Math.max(2, width - visibleWidth(leftMinPlain) - visibleWidth(right.plain));\n\t\t\treturn leftMin + \" \".repeat(pad) + right.styled;\n\t\t}\n\t}\n\tif (visibleWidth(leftFullPlain) <= width) {\n\t\treturn leftFull + \" \".repeat(width - visibleWidth(leftFullPlain));\n\t}\n\treturn truncateToWidth(leftMin, width, \"…\");\n}\n\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\treturn `${(count / 1000000).toFixed(1)}M`;\n}\n\nfunction formatTaskLine(\n\ttask: Task,\n\twidth: number,\n\tframe: number,\n\toptions: { grouped?: boolean; owner?: TaskAgent; treePrefix?: string } = {},\n): string {\n\tconst isProgress = task.status === \"in_progress\";\n\tconst iconGlyph = isProgress\n\t\t? (SPINNER_FRAMES[frame] ?? TASK_STATUS_ICON.in_progress)\n\t\t: TASK_STATUS_ICON[task.status];\n\tconst icon = theme.fg(taskStatusColor(task.status), iconGlyph);\n\n\t// In grouped views the group header already carries the row's origin, so the\n\t// owner glyph and tag are suppressed; the rows sit on a faint indent guide.\n\tconst grouped = options.grouped === true;\n\tconst indent = grouped ? theme.fg(\"borderMuted\", GROUP_INDENT_PLAIN) : \"\";\n\n\t// Owner marker between the status icon and the id, derived from the owning\n\t// agent's kind so the flat lens attributes rows the same way the grouped\n\t// lenses do (a roster-less owner falls back on the task's source). MCP rows\n\t// have no owning agent and keep their own ⧉ marker. Every row carries one\n\t// cell, so the id column stays aligned.\n\tconst isMcp = task.source === \"mcp\";\n\tconst ownerKind = options.owner?.kind ?? (task.source === \"subagent\" ? \"subagent\" : \"main\");\n\tconst sourceGlyph = isMcp ? MCP_SOURCE_GLYPH : AGENT_GLYPH[ownerKind];\n\t// Subagent rows carry their agent's identity color on the glyph (and tag\n\t// below) — the same hue as the chat's `Agent [type]` line and TaskOutput —\n\t// so a user can trace one agent across the whole TUI at a glance.\n\tconst agentTypeName = !isMcp && ownerKind === \"subagent\" ? (task.subagentMode ?? task.agent) : undefined;\n\tconst styledSource = grouped\n\t\t? \"\"\n\t\t: agentTypeName\n\t\t\t? theme.fg(agentColorFor(agentTypeName), sourceGlyph)\n\t\t\t: theme.fg(\"dim\", sourceGlyph);\n\n\t// Origin tag prefixed to the title, naming who runs the row: the subagent\n\t// type (\"[explore]\"), the team role's name (\"[planner]\"), or the MCP server\n\t// (\"[github]\"; \"[MCP]\" when no server label was recorded). Drawn in accent,\n\t// parallel to the chat's `Agent [explore]` / `MCP [server › tool]`. Grouped\n\t// rows drop it — the group header carries the origin — except MCP rows,\n\t// which group under main without being main's own work.\n\tlet tag = \"\";\n\tlet tagColor: ThemeColor = \"accent\";\n\tif (isMcp) tag = `[${task.subagentMode ?? \"MCP\"}]`;\n\telse if (!grouped) {\n\t\tif (task.subagentMode) {\n\t\t\ttag = `[${task.subagentMode}]`;\n\t\t\ttagColor = agentColorFor(task.subagentMode);\n\t\t} else if (ownerKind === \"role\" && options.owner) {\n\t\t\ttag = `[${options.owner.name}]`;\n\t\t\ttagColor = agentColorFor(options.owner.name);\n\t\t}\n\t}\n\tconst styledTag = tag ? `${theme.fg(tagColor, tag)} ` : \"\";\n\tconst title = task.title;\n\t// The title carries the line. Done titles fade to muted\n\t// (settled work), pending dim (not started), active goes bold, failed turns red.\n\tlet styledTitle: string;\n\tswitch (task.status) {\n\t\tcase \"done\":\n\t\t\tstyledTitle = theme.fg(\"muted\", title);\n\t\t\tbreak;\n\t\tcase \"pending\":\n\t\t\tstyledTitle = theme.fg(\"dim\", title);\n\t\t\tbreak;\n\t\tcase \"failed\":\n\t\t\tstyledTitle = theme.fg(\"error\", title);\n\t\t\tbreak;\n\t\tcase \"cancelled\":\n\t\t\tstyledTitle = theme.fg(\"dim\", theme.strikethrough(title));\n\t\t\tbreak;\n\t\tcase \"in_progress\":\n\t\t\tstyledTitle = theme.bold(title);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tstyledTitle = title;\n\t}\n\n\t// Right column: settled rows carry their token usage; the\n\t// active row reads `running…`, pending rows read `queued`.\n\tlet rightPlain = \"\";\n\tlet rightStyled = \"\";\n\tif (task.status === \"done\" || task.status === \"failed\") {\n\t\tlet tokenText = \"\";\n\t\tif (task.usage) {\n\t\t\tconst totalTok = task.usage.input + task.usage.output;\n\t\t\tif (totalTok > 0) tokenText = formatTokens(totalTok);\n\t\t}\n\t\tif (tokenText) {\n\t\t\trightPlain = tokenText;\n\t\t\trightStyled = theme.fg(\"muted\", tokenText);\n\t\t}\n\t} else if (task.status === \"cancelled\") {\n\t\trightPlain = \"cancelled\";\n\t\trightStyled = theme.fg(\"dim\", rightPlain);\n\t} else if (task.status === \"in_progress\") {\n\t\t// Surface the owning agent's live activity (the tool it's currently running,\n\t\t// fed by the pool's task_progress events) so a delegated row reads \"⋯ grep\"\n\t\t// rather than a static \"running…\" — the difference between looking busy and\n\t\t// looking stuck. Falls back to \"running…\" between tools (activity cleared) or\n\t\t// when no owner is resolved. Each running row carries its own live timer —\n\t\t// the per-run clock the header (a single wall-clock span) deliberately\n\t\t// doesn't show. Ticks smoothly because active rows re-render per spinner\n\t\t// frame anyway.\n\t\tconst liveActivity = options.owner?.activity;\n\t\tconst runFor = formatDurationSecs(taskElapsedSecs(task));\n\t\trightPlain = `${liveActivity ? `⋯ ${liveActivity}` : \"running…\"} · ${runFor}`;\n\t\trightStyled =\n\t\t\ttheme.fg(\"warning\", liveActivity ? `⋯ ${liveActivity}` : \"running…\") + theme.fg(\"dim\", ` · ${runFor}`);\n\t} else if (task.status === \"pending\") {\n\t\trightPlain = \"queued\";\n\t\trightStyled = theme.fg(\"dim\", rightPlain);\n\t}\n\n\t// A warning note (e.g. inherited-model fallback, exhaustion skip) takes over the\n\t// right column as a ⚠ cue, replacing the usage/status stamp for that row.\n\tif (task.note) {\n\t\trightPlain = `${WARNING_GLYPH} ${task.note}`;\n\t\trightStyled = theme.fg(\"warning\", rightPlain);\n\t}\n\n\tconst rightWidth = rightPlain ? visibleWidth(rightPlain) + 1 : 0;\n\tconst leftWidth = Math.max(0, width - rightWidth);\n\n\t// truncateToWidth measures visible width (ANSI-aware), so the styled left can be\n\t// truncated against the full left budget directly. Subtracting the prefix here\n\t// (as a prior version did) truncated titles early and unevenly per id width.\n\t// In the subagents tree, a depth-first connector prefix (└─/├─/│) sits before\n\t// the row's glyph; roots pass an empty prefix and read exactly like flat rows.\n\tconst treePrefix = options.treePrefix ? theme.fg(\"borderMuted\", options.treePrefix) : \"\";\n\tconst leftBody = grouped\n\t\t? `${indent}${icon} ${styledTag}${styledTitle}`\n\t\t: `${treePrefix}${icon} ${styledSource} ${styledTag}${styledTitle}`;\n\tconst left = truncateToWidth(leftBody, leftWidth, \"…\");\n\n\t// Pad every row to the full pane width so rows align regardless of whether they\n\t// carry a right column (token usage / running… / queued).\n\tif (!rightPlain) {\n\t\tconst pad = Math.max(0, width - visibleWidth(left));\n\t\treturn left + \" \".repeat(pad);\n\t}\n\n\tconst pad = Math.max(1, width - visibleWidth(left) - visibleWidth(rightPlain));\n\treturn left + \" \".repeat(pad) + rightStyled;\n}\n\n/**\n * Filter tasks and agents to the teams lens: only role agents and the tasks they\n * own. The flat and subagents lenses do their own ownership filtering inline (by\n * source and parentTaskId), so this is teams-specific.\n */\nfunction filterTasksForView(\n\ttasks: readonly Task[],\n\tagents: readonly TaskAgent[],\n\t_view: TaskPanelView,\n): { filteredTasks: readonly Task[]; filteredAgents: readonly TaskAgent[] } {\n\tconst roleIds = new Set(agents.filter((a) => a.kind === \"role\").map((a) => a.id));\n\treturn {\n\t\tfilteredAgents: agents.filter((a) => a.kind === \"role\"),\n\t\tfilteredTasks: tasks.filter((t) => roleIds.has(taskOwnerId(t))),\n\t};\n}\n\n/** One renderable row of the subagents lens: a task plus its tree connector. */\ninterface SubagentTreeRow {\n\ttask: Task;\n\ttreePrefix: string;\n}\n\n/**\n * The subagents lens's rows in render order: a depth-first walk of the\n * delegated forest. Roots are parentless subagent/MCP tasks, plus orphans —\n * children whose parent was dropped from the store (their `parentTaskId` no\n * longer resolves), which used to be counted by the header but never rendered.\n * A visited set guards against `parentTaskId` cycles, so a corrupt link can\n * never hang the render loop. Both the lens filter (header counts) and the\n * renderer consume this list, so count and rendering can never disagree.\n */\nfunction subagentLensRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\tconst ids = new Set<number>();\n\tfor (const task of tasks) ids.add(task.id);\n\tconst childrenByParent = new Map<number, Task[]>();\n\tfor (const task of tasks) {\n\t\tif (task.parentTaskId === undefined) continue;\n\t\tconst siblings = childrenByParent.get(task.parentTaskId);\n\t\tif (siblings) siblings.push(task);\n\t\telse childrenByParent.set(task.parentTaskId, [task]);\n\t}\n\tconst roots = tasks.filter(\n\t\t(t) =>\n\t\t\t(t.parentTaskId === undefined && (t.source === \"subagent\" || t.source === \"mcp\")) ||\n\t\t\t(t.parentTaskId !== undefined && !ids.has(t.parentTaskId)),\n\t);\n\n\tconst rows: SubagentTreeRow[] = [];\n\tconst visited = new Set<number>();\n\tconst walk = (task: Task, prefix: string, isLast: boolean, isRoot: boolean): void => {\n\t\tif (visited.has(task.id)) return;\n\t\tvisited.add(task.id);\n\t\trows.push({ task, treePrefix: isRoot ? \"\" : `${prefix}${isLast ? \"└─ \" : \"├─ \"}` });\n\t\tconst kids = childrenByParent.get(task.id) ?? [];\n\t\tconst childPrefix = isRoot ? \"\" : `${prefix}${isLast ? \" \" : \"│ \"}`;\n\t\tfor (let i = 0; i < kids.length; i++) {\n\t\t\twalk(kids[i] as Task, childPrefix, i === kids.length - 1, false);\n\t\t}\n\t};\n\tfor (const root of roots) walk(root, \"\", true, true);\n\treturn rows;\n}\n\n/**\n * The flat (\"tasks\") lens's rows in render order: the main agent's TodoWrite\n * plan, with each subagent run nested (└─/├─) under the plan item it was\n * dispatched for (`linkedTaskId`, recorded when exactly one item was\n * in_progress). This is the visual tie between the plan and the agents\n * executing it — previously a spawn was linked in spirit but invisible here,\n * living only in the subagents lens. Runs with no link (or a dangling one —\n * their todo was replaced) stay out of this lens; they remain visible in the\n * subagents tree.\n */\nfunction flatLensRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\tconst linkedByTodo = new Map<number, Task[]>();\n\tfor (const task of tasks) {\n\t\tif (task.linkedTaskId === undefined || task.source !== \"subagent\") continue;\n\t\tconst siblings = linkedByTodo.get(task.linkedTaskId);\n\t\tif (siblings) siblings.push(task);\n\t\telse linkedByTodo.set(task.linkedTaskId, [task]);\n\t}\n\tconst rows: SubagentTreeRow[] = [];\n\tfor (const task of tasks) {\n\t\tif (!isMainTask(task)) continue;\n\t\trows.push({ task, treePrefix: \"\" });\n\t\tconst linked = linkedByTodo.get(task.id) ?? [];\n\t\tfor (let i = 0; i < linked.length; i++) {\n\t\t\trows.push({ task: linked[i] as Task, treePrefix: i === linked.length - 1 ? \"└─ \" : \"├─ \" });\n\t\t}\n\t}\n\treturn rows;\n}\n\n/**\n * Scope the full task list to the tasks visible in the given lens. The header,\n * state stamp, and done/total count are derived from this subset so they match\n * exactly what the user sees in the current view.\n */\nfunction filterTasksForLens(\n\ttasks: readonly Task[],\n\tagents: readonly TaskAgent[],\n\tview: TaskPanelView,\n): readonly Task[] {\n\tswitch (view) {\n\t\tcase \"flat\":\n\t\t\t// Same walk the renderer uses (plan items + their linked runs), so the\n\t\t\t// header's done/total always matches the visible rows.\n\t\t\treturn flatLensRows(tasks).map((row) => row.task);\n\t\tcase \"subagents\":\n\t\t\t// Exactly the rows the tree renders (same walk), so the header's\n\t\t\t// done/total always matches the visible rows.\n\t\t\treturn subagentLensRows(tasks).map((row) => row.task);\n\t\tcase \"teams\": {\n\t\t\tconst roleIds = new Set<string>();\n\t\t\tfor (const a of agents) {\n\t\t\t\tif (a.kind === \"role\") roleIds.add(a.id);\n\t\t\t}\n\t\t\treturn tasks.filter((t) => roleIds.has(taskOwnerId(t)));\n\t\t}\n\t}\n}\n\n/** Fallback group metadata when a task's owner has no roster entry. */\nfunction defaultAgentMeta(id: string): TaskAgent {\n\treturn id === \"main\"\n\t\t? { id, name: \"main\", role: \"orchestrator\", kind: \"main\" }\n\t\t: { id, name: id, role: \"subagent\", kind: \"subagent\" };\n}\n\n/**\n * Partition the flat task list into owner groups. An explicit task.agent wins;\n * otherwise a subagent-sourced task falls into a generic \"subagent\" group and\n * everything else into \"main\". Group order is deterministic — main first, then\n * roster order, then stragglers — never reordered by status.\n */\nfunction groupTasks(\n\ttasks: readonly Task[],\n\tagents: readonly TaskAgent[],\n): Array<{ id: string; meta: TaskAgent; items: Task[] }> {\n\tconst meta = new Map<string, TaskAgent>(agents.map((a) => [a.id, a]));\n\tconst groups = new Map<string, Task[]>();\n\tfor (const task of tasks) {\n\t\tconst owner = taskOwnerId(task);\n\t\tconst items = groups.get(owner);\n\t\tif (items) items.push(task);\n\t\telse groups.set(owner, [task]);\n\t}\n\tconst order: string[] = [];\n\tconst ordered = new Set<string>();\n\tconst push = (id: string) => {\n\t\tif (!ordered.has(id)) {\n\t\t\tordered.add(id);\n\t\t\torder.push(id);\n\t\t}\n\t};\n\tif (groups.has(\"main\")) push(\"main\");\n\tfor (const agent of agents) {\n\t\tif (groups.has(agent.id)) push(agent.id);\n\t}\n\tfor (const id of groups.keys()) push(id);\n\treturn order.map((id) => ({\n\t\tid,\n\t\tmeta: meta.get(id) ?? defaultAgentMeta(id),\n\t\titems: groups.get(id) ?? [],\n\t}));\n}\n\n/**\n * Group header for the grouped views: owner glyph + bold name + role, the\n * agent's lifecycle `[state]` tag, an optional handoff arrow (teams), then the\n * agent's own token/cost totals + done/total on the right. Mirrors the footer's\n * \"every number accounted for\" stance, but per agent.\n */\nfunction formatGroupHeader(meta: TaskAgent, items: readonly Task[], width: number, selected = false): string {\n\t// A focused role row swaps its ▸ for a filled ▶ in accent — the team-focus\n\t// selection cursor (the owner-kind glyph mapping itself is unchanged).\n\t// Spawned/team agents carry their identity color on the glyph and name\n\t// (hashed from the agent type, \"explore#2\" → \"explore\"), matching the same\n\t// agent's color in the chat and TaskOutput; main keeps the accent.\n\tconst identityColor: ThemeColor =\n\t\tmeta.kind === \"main\" ? AGENT_GLYPH_COLOR[meta.kind] : agentColorFor(agentTypeOfName(meta.name));\n\tconst glyph = selected\n\t\t? theme.fg(\"accent\", SELECTED_GLYPH)\n\t\t: theme.fg(identityColor, AGENT_GLYPH[meta.kind] ?? AGENT_GLYPH.subagent);\n\tconst name = selected\n\t\t? theme.bold(theme.fg(\"accent\", meta.name))\n\t\t: meta.kind === \"main\"\n\t\t\t? theme.bold(meta.name)\n\t\t\t: theme.bold(theme.fg(identityColor, meta.name));\n\t// Roles read as a dim \"· role\" suffix for spawned/team agents; the main\n\t// orchestrator's role sits brighter (muted), matching the design's .grp-role.\n\tconst role = meta.role\n\t\t? meta.kind === \"main\"\n\t\t\t? ` ${theme.fg(\"muted\", meta.role)}`\n\t\t\t: theme.fg(\"dim\", ` · ${meta.role}`)\n\t\t: \"\";\n\tconst state = meta.state ? ` ${theme.fg(AGENT_STATE_COLOR[meta.state] ?? \"dim\", `[${meta.state}]`)}` : \"\";\n\t// Live activity for a running subagent (e.g. the tool it's executing). Empty\n\t// string means idle/between-tools, so it disappears rather than lingering stale.\n\tconst activity = meta.activity ? theme.fg(\"dim\", ` ⋯ ${meta.activity}`) : \"\";\n\tconst handoff = meta.handoff ? ` ${theme.fg(\"dim\", meta.handoff)}` : \"\";\n\n\tconst done = items.filter((t) => t.status === \"done\").length;\n\tconst countPlain = `${done}/${items.length}`;\n\tconst count = theme.fg(\"muted\", `${done}`) + theme.fg(\"dim\", \"/\") + theme.fg(\"muted\", `${items.length}`);\n\tconst stats = meta.stats;\n\tlet rightPlain = countPlain;\n\tlet rightStyled = count;\n\tif (stats && (stats.input > 0 || stats.output > 0 || stats.cost > 0)) {\n\t\tconst statsPlain = `↑${formatTokens(stats.input)} ↓${formatTokens(stats.output)} · $${stats.cost.toFixed(3)}`;\n\t\trightPlain = `${statsPlain} ${countPlain}`;\n\t\trightStyled = `${theme.fg(\"dim\", statsPlain)} ${count}`;\n\t}\n\n\tconst rightWidth = visibleWidth(rightPlain) + 1;\n\tconst leftWidth = Math.max(0, width - rightWidth);\n\tconst left = truncateToWidth(`${glyph} ${name}${role}${state}${activity}${handoff}`, leftWidth, \"…\");\n\tconst pad = Math.max(1, width - visibleWidth(left) - visibleWidth(rightPlain));\n\treturn left + \" \".repeat(pad) + rightStyled;\n}\n\n/**\n * Task panel rendered just above the editor prompt.\n *\n * - A state-colored left rail groups the pane (working=warning, reviewed=success,\n * stopped=error) without drawing a box.\n * - A ledger header tops the list: a state stamp + deterministic progress bar +\n * done/total count on the left, the per-turn token/elapsed/cost delta on the right.\n * - Shows all tasks with all statuses (pending / in_progress / done / failed).\n * The active row animates a braille spinner; pending rows read `queued`.\n * - A single-cell owner glyph (◆ main / ◇ subagent / ▸ team role / ⧉ MCP) sits\n * before the id, derived from the owning agent's kind, so every row's origin\n * is readable at a glance even in the flat lens. A text origin tag before the\n * title names the owner: the subagent type (\"[explore]\"), the team role\n * (\"[planner]\", fed by `--team <url>`), or the MCP server (\"[github]\").\n * - Three views split by ownership (cycled via app.tasks.cycleView, shown as a\n * `tasks · subagents · teams` switcher in the header):\n * - flat (\"tasks\") — the main agent's own TodoWrite plan, with each\n * dispatched subagent run nested under the plan item it was dispatched\n * for (linkedTaskId), so the plan and the agents executing it read as\n * one picture;\n * - subagents — a recursive task tree over delegated work, where a subagent\n * that spawned a subagent shows its nested tasks (roots are the dispatched\n * subagents and direct MCP calls; children link via parentTaskId, merged\n * across the process boundary). Each task is its own node keeping its\n * [subagentMode]/[server] tag, with depth drawn by └─/├─/│ connectors; a\n * run with only top-level tasks reads flat (no extra indent);\n * - teams — grouped by named role-agent with handoff arrows.\n * The cycle is adaptive: empty lenses are skipped and dropped from the\n * switcher, which hides entirely when only one lens has content. An empty flat\n * lens (no main task) falls through to subagents when delegated work exists.\n * - LIFO within the window: newest tasks appear at the bottom (closest to the prompt).\n * - Finished tasks carry their wall-clock cost and stay visible until the next\n * user message arrives (see taskStore.reset()), not the moment they finish.\n * - Collapses to zero lines when there are no tasks — unless a team roster is\n * registered (`--team`), in which case the empty flat lens falls through to\n * teams and every role renders as a placeholder group, so idle roles are\n * visible from startup.\n */\nexport class TaskPanelComponent implements Component, Focusable {\n\tprivate readonly ui: TUI | null;\n\tprivate frame = 0;\n\tprivate animationTimer: ReturnType<typeof setInterval> | null = null;\n\tprivate view: TaskPanelView = \"flat\";\n\tprivate disposed = false;\n\n\t// Render memoization. The spinner re-renders the whole pane every 80ms, but\n\t// between store mutations only the in-progress rows actually change (spinner\n\t// frame + live activity). Settled/pending row strings and derived structures\n\t// (agentById, the subagents tree walk) are cached and invalidated by the\n\t// store's mutation version + pane width, so a frame tick reuses them instead\n\t// of rebuilding every string.\n\tprivate memoVersion = -1;\n\tprivate memoWidth = -1;\n\tprivate lineMemo = new Map<string, string>();\n\tprivate agentByIdMemo: Map<string, TaskAgent> | null = null;\n\tprivate subagentRowsMemo: SubagentTreeRow[] | null = null;\n\tprivate flatRowsMemo: SubagentTreeRow[] | null = null;\n\n\t/** Drop all memoized render state when the store or width changed. */\n\tprivate syncMemo(width: number): void {\n\t\tconst version = taskStore.version();\n\t\tif (version === this.memoVersion && width === this.memoWidth) return;\n\t\tthis.memoVersion = version;\n\t\tthis.memoWidth = width;\n\t\tthis.lineMemo.clear();\n\t\tthis.agentByIdMemo = null;\n\t\tthis.subagentRowsMemo = null;\n\t\tthis.flatRowsMemo = null;\n\t}\n\n\tprivate agentById(agents: readonly TaskAgent[]): Map<string, TaskAgent> {\n\t\tif (!this.agentByIdMemo) {\n\t\t\tthis.agentByIdMemo = new Map(agents.map((a) => [a.id, a]));\n\t\t}\n\t\treturn this.agentByIdMemo;\n\t}\n\n\tprivate subagentRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\t\tif (!this.subagentRowsMemo) {\n\t\t\tthis.subagentRowsMemo = subagentLensRows(tasks);\n\t\t}\n\t\treturn this.subagentRowsMemo;\n\t}\n\n\tprivate flatRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\t\tif (!this.flatRowsMemo) {\n\t\t\tthis.flatRowsMemo = flatLensRows(tasks);\n\t\t}\n\t\treturn this.flatRowsMemo;\n\t}\n\n\t/**\n\t * formatTaskLine with caching for rows that don't animate. In-progress rows\n\t * depend on the spinner frame and the owner's live activity, so they render\n\t * fresh every frame; everything else is stable until the next store mutation\n\t * or width change (handled by syncMemo).\n\t */\n\tprivate taskLine(\n\t\ttask: Task,\n\t\twidth: number,\n\t\toptions: { grouped?: boolean; owner?: TaskAgent; treePrefix?: string } = {},\n\t): string {\n\t\tif (task.status === \"in_progress\") {\n\t\t\treturn formatTaskLine(task, width, this.frame, options);\n\t\t}\n\t\tconst key = `${task.id}:${options.grouped ? 1 : 0}:${options.treePrefix ?? \"\"}:${options.owner?.kind ?? \"\"}`;\n\t\tconst hit = this.lineMemo.get(key);\n\t\tif (hit !== undefined) return hit;\n\t\tconst line = formatTaskLine(task, width, this.frame, options);\n\t\tthis.lineMemo.set(key, line);\n\t\treturn line;\n\t}\n\n\t// Team focus mode: when the TUI focuses the panel, role rows become a\n\t// navigable list (↑/↓ select, n nudge, a attach, q/esc back). The selection\n\t// is tracked by role name so a roster reorder doesn't move the cursor.\n\tfocused = false;\n\tprivate selectedRole: string | undefined;\n\t/** Open the inline nudge editor for the selected role. */\n\tonNudge?: (role: string) => void;\n\t/** Open the attach side panel for the selected role. */\n\tonAttach?: (role: string) => void;\n\t/** Leave team focus (focus returns to the main editor). */\n\tonExitFocus?: () => void;\n\n\tconstructor(ui?: TUI) {\n\t\tthis.ui = ui ?? null;\n\t}\n\n\tinvalidate(): void {\n\t\t// Force the next render to rebuild its memoized rows/structures.\n\t\tthis.memoVersion = -1;\n\t}\n\n\tprivate roleAgents(): TaskAgent[] {\n\t\treturn taskStore.agents().filter((a) => a.kind === \"role\");\n\t}\n\n\t/** The role the team-focus cursor sits on (clamped to the live roster). */\n\tfocusedRole(): string | undefined {\n\t\tconst roles = this.roleAgents();\n\t\tif (roles.length === 0) return undefined;\n\t\tconst match = roles.find((a) => a.name === this.selectedRole);\n\t\treturn (match ?? roles[0]).name;\n\t}\n\n\thandleInput(data: string): void {\n\t\tconst roles = this.roleAgents();\n\t\tif (roles.length === 0) {\n\t\t\tthis.onExitFocus?.();\n\t\t\treturn;\n\t\t}\n\t\tconst keybindings = getKeybindings();\n\t\tconst index = Math.max(\n\t\t\t0,\n\t\t\troles.findIndex((a) => a.name === this.selectedRole),\n\t\t);\n\t\tif (keybindings.matches(data, \"tui.select.up\")) {\n\t\t\tthis.selectedRole = roles[Math.max(0, index - 1)].name;\n\t\t} else if (keybindings.matches(data, \"tui.select.down\")) {\n\t\t\tthis.selectedRole = roles[Math.min(roles.length - 1, index + 1)].name;\n\t\t} else if (matchesAppKey(data, \"app.team.nudge\")) {\n\t\t\tconst role = this.focusedRole();\n\t\t\tif (role) this.onNudge?.(role);\n\t\t} else if (matchesAppKey(data, \"app.team.attach\")) {\n\t\t\tconst role = this.focusedRole();\n\t\t\tif (role) this.onAttach?.(role);\n\t\t} else if (matchesKey(data, \"q\") || keybindings.matches(data, \"tui.select.cancel\")) {\n\t\t\tthis.onExitFocus?.();\n\t\t}\n\t\tthis.ui?.requestRender();\n\t}\n\n\tgetView(): TaskPanelView {\n\t\treturn this.view;\n\t}\n\n\tsetView(view: TaskPanelView): void {\n\t\tthis.view = view;\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Advance to the next view lens with content (flat → subagents → teams →\n\t * flat), skipping empty lenses. With nothing delegated this is a no-op on\n\t * flat; a stale view (its lens emptied since selection) snaps back to flat.\n\t */\n\tcycleView(): TaskPanelView {\n\t\tconst available = availableViews(taskStore.list(), taskStore.agents());\n\t\tconst idx = available.indexOf(this.view);\n\t\tthis.view = available[(idx + 1) % available.length] ?? \"flat\";\n\t\tthis.ui?.requestRender();\n\t\treturn this.view;\n\t}\n\n\t/** Run the spinner timer only while a task is active, ticking re-renders. */\n\tprivate ensureAnimation(active: boolean): void {\n\t\tif (this.disposed) {\n\t\t\tif (this.animationTimer) {\n\t\t\t\tclearInterval(this.animationTimer);\n\t\t\t\tthis.animationTimer = null;\n\t\t\t\tthis.frame = 0;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (active && this.ui && !this.animationTimer) {\n\t\t\tthis.animationTimer = setInterval(() => {\n\t\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\t\tthis.ui?.requestRender();\n\t\t\t}, SPINNER_INTERVAL_MS);\n\t\t\tthis.animationTimer.unref?.();\n\t\t} else if (!active && this.animationTimer) {\n\t\t\tclearInterval(this.animationTimer);\n\t\t\tthis.animationTimer = null;\n\t\t\tthis.frame = 0;\n\t\t}\n\t}\n\n\t/** Stop the spinner timer. Call on teardown. */\n\tdispose(): void {\n\t\tif (this.animationTimer) {\n\t\t\tclearInterval(this.animationTimer);\n\t\t\tthis.animationTimer = null;\n\t\t}\n\t\tthis.disposed = true;\n\t}\n\n\trender(width: number): string[] {\n\t\tif (this.disposed) return [];\n\n\t\tthis.syncMemo(width);\n\t\tconst tasks = taskStore.list();\n\t\tconst allAgents = taskStore.agents();\n\n\t\t// Resolve the lens: keep the stored view while it still has content, else\n\t\t// fall through to the first available lens (flat → subagents → teams). The\n\t\t// stored view is untouched so an explicit setView choice resumes once its\n\t\t// content returns. This is how an empty flat lens (no main/TodoWrite task)\n\t\t// falls through to the subagents tree when only delegated work exists, and\n\t\t// how an empty pane falls through to the teams roster at startup.\n\t\tconst available = availableViews(tasks, allAgents);\n\t\tlet view: TaskPanelView = available.includes(this.view) ? this.view : (available[0] ?? \"flat\");\n\n\t\t// Team focus always operates on the teams lens — the focused role list is\n\t\t// exactly what the lens renders, so the cursor is never invisible.\n\t\tif (this.focused) view = \"teams\";\n\n\t\t// In teams view the roster itself is content: role agents render as\n\t\t// placeholder groups even without tasks (idle roles at startup, queued\n\t\t// upcoming work).\n\t\tconst hasRoleRoster = view === \"teams\" && allAgents.some((a) => a.kind === \"role\");\n\n\t\tif (tasks.length === 0 && !hasRoleRoster) {\n\t\t\tthis.ensureAnimation(false);\n\t\t\treturn [];\n\t\t}\n\n\t\t// Scope all visual state to the tasks visible in the current lens so the\n\t\t// animation, rail color, and header count match exactly what the user sees.\n\t\t// The flat and subagents lenses derive from their memoized row walks so\n\t\t// filter and render share one computation (one source of truth for counts).\n\t\tconst lensTasks =\n\t\t\tview === \"subagents\"\n\t\t\t\t? this.subagentRows(tasks).map((row) => row.task)\n\t\t\t\t: view === \"flat\"\n\t\t\t\t\t? this.flatRows(tasks).map((row) => row.task)\n\t\t\t\t\t: filterTasksForLens(tasks, allAgents, view);\n\t\tconst hasActive = lensTasks.some((t) => t.status === \"in_progress\");\n\t\tthis.ensureAnimation(hasActive);\n\n\t\tconst state = panelState(lensTasks);\n\t\tconst totalSecs = turnElapsedSecs(lensTasks);\n\t\tconst railColor = STATE_PRESENTATION[state].color;\n\t\tconst gutter = `${theme.fg(railColor, RAIL)} `;\n\t\tconst inner = Math.max(0, width - visibleWidth(RAIL) - 1);\n\n\t\tconst lines: string[] = [gutter + formatHeader(lensTasks, inner, state, totalSecs, view, available)];\n\n\t\tif (view === \"flat\") {\n\t\t\t// The main agent's TodoWrite plan, with each dispatched run nested under\n\t\t\t// the plan item it executes (see flatLensRows). Resolve each row's owner\n\t\t\t// from the roster so the glyph/tag reflect the owning agent's kind and a\n\t\t\t// nested run shows its live activity + timer.\n\t\t\tconst agentById = this.agentById(allAgents);\n\t\t\tfor (const { task, treePrefix } of this.flatRows(tasks)) {\n\t\t\t\tlines.push(gutter + this.taskLine(task, inner, { treePrefix, owner: agentById.get(taskOwnerId(task)) }));\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (view === \"subagents\") {\n\t\t\t// A recursive task tree over the delegated forest: roots are the main\n\t\t\t// agent's dispatched subagents (and direct MCP calls), children are the\n\t\t\t// tasks they spawned in turn (linked by parentTaskId, merged across the\n\t\t\t// process boundary), so a subagent that spawned a subagent is visible.\n\t\t\t// Each task is its own node keeping its [subagentMode]/[server] tag; depth\n\t\t\t// is drawn with └─/├─/│ connectors (see subagentLensRows for orphan and\n\t\t\t// cycle handling). With only top-level tasks the roots carry an empty\n\t\t\t// prefix and read exactly like flat rows (no extra indent). Owners are\n\t\t\t// resolved so an in-progress row shows the run's live tool activity\n\t\t\t// (⋯ grep) instead of a static \"running…\".\n\t\t\tconst agentById = this.agentById(allAgents);\n\t\t\tfor (const { task, treePrefix } of this.subagentRows(tasks)) {\n\t\t\t\tlines.push(\n\t\t\t\t\tgutter +\n\t\t\t\t\t\tthis.taskLine(task, inner, {\n\t\t\t\t\t\t\ttreePrefix,\n\t\t\t\t\t\t\towner: agentById.get(taskOwnerId(task)),\n\t\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\t// teams view: role-agent groups with handoff connectors and queued placeholders.\n\t\tconst { filteredTasks, filteredAgents } = filterTasksForView(tasks, allAgents, view);\n\t\tconst groups = groupTasks(filteredTasks, filteredAgents);\n\t\tconst groupIds = new Set(groups.map((g) => g.id));\n\t\t// Role agents with no tasks still get a group header: idle roles are the\n\t\t// roster at startup, queued ones upcoming work, done/failed ones the\n\t\t// state they settled in after reset() dropped their tasks.\n\t\tfor (const agent of filteredAgents) {\n\t\t\tif (!groupIds.has(agent.id)) {\n\t\t\t\tgroups.push({ id: agent.id, meta: agent, items: [] });\n\t\t\t}\n\t\t}\n\t\tconst cursorRole = this.focused ? this.focusedRole() : undefined;\n\t\tfor (const group of groups) {\n\t\t\tconst selected = cursorRole !== undefined && group.meta.kind === \"role\" && group.meta.name === cursorRole;\n\t\t\tlines.push(gutter + formatGroupHeader(group.meta, group.items, inner, selected));\n\t\t\tfor (const task of group.items) {\n\t\t\t\tlines.push(gutter + this.taskLine(task, inner, { grouped: true }));\n\t\t\t}\n\t\t\t// Forward-handoff connector: emit \"└──→ name\" only for \"→ name\" arrows\n\t\t\t// (not back-references \"← name\"), and only when the target exists in the\n\t\t\t// visible role roster.\n\t\t\tconst { handoff } = group.meta;\n\t\t\tif (handoff) {\n\t\t\t\tconst arrowIdx = handoff.indexOf(\"→ \");\n\t\t\t\tif (arrowIdx !== -1) {\n\t\t\t\t\tconst nextName = handoff.slice(arrowIdx + 2).trim();\n\t\t\t\t\tif (filteredAgents.some((a) => a.name === nextName)) {\n\t\t\t\t\t\tconst connectorPrefix = `${GROUP_INDENT_PLAIN} └──→ `;\n\t\t\t\t\t\tconst connectorPad = Math.max(0, inner - visibleWidth(connectorPrefix) - visibleWidth(nextName));\n\t\t\t\t\t\tlines.push(\n\t\t\t\t\t\t\tgutter +\n\t\t\t\t\t\t\t\ttheme.fg(\"borderMuted\", connectorPrefix) +\n\t\t\t\t\t\t\t\ttheme.fg(\"dim\", nextName) +\n\t\t\t\t\t\t\t\t\" \".repeat(connectorPad),\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (this.focused) {\n\t\t\t// House hint style (see keybinding-hints): dim key + muted description,\n\t\t\t// muted · separators. Arrows and q/esc are literal by convention; the\n\t\t\t// nudge/attach keys resolve from the live keybinding config.\n\t\t\tconst sep = theme.fg(\"muted\", \" · \");\n\t\t\tconst hint = [\n\t\t\t\trawKeyHint(\"↑/↓\", \"select\"),\n\t\t\t\trawKeyHint(appKeyLabel(\"app.team.nudge\"), \"nudge\"),\n\t\t\t\trawKeyHint(appKeyLabel(\"app.team.attach\"), \"attach\"),\n\t\t\t\trawKeyHint(\"q/esc\", \"back\"),\n\t\t\t].join(sep);\n\t\t\tlines.push(gutter + truncateToWidth(hint, inner, \"…\"));\n\t\t}\n\t\treturn lines;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"task-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/task-panel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAsD1E;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;AAuoB3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,qBAAa,kBAAmB,YAAW,SAAS,EAAE,SAAS;IAC9D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAa;IAChC,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,IAAI,CAAyB;IACrC,OAAO,CAAC,QAAQ,CAAS;IAQzB,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,QAAQ,CAA6B;IAC7C,OAAO,CAAC,aAAa,CAAuC;IAC5D,OAAO,CAAC,gBAAgB,CAAkC;IAC1D,OAAO,CAAC,YAAY,CAAkC;IAEtD,sEAAsE;IACtE,OAAO,CAAC,QAAQ;IAWhB,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,QAAQ;IAOhB;;;;;OAKG;IACH,OAAO,CAAC,QAAQ;IAmBhB,OAAO,UAAS;IAChB,OAAO,CAAC,YAAY,CAAqB;IACzC,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB,YAAY,EAAE,CAAC,EAAE,GAAG,EAEnB;IAED,UAAU,IAAI,IAAI,CAGjB;IAED,OAAO,CAAC,UAAU;IAIlB,2EAA2E;IAC3E,WAAW,IAAI,MAAM,GAAG,SAAS,CAKhC;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAyB9B;IAED,OAAO,IAAI,aAAa,CAEvB;IAED,OAAO,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAGjC;IAED;;;;OAIG;IACH,SAAS,IAAI,aAAa,CAMzB;IAED,6EAA6E;IAC7E,OAAO,CAAC,eAAe;IAsBvB,gDAAgD;IAChD,OAAO,IAAI,IAAI,CAMd;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA6I9B;CACD","sourcesContent":["import type { Component, Focusable, TUI } from \"@kolisachint/hoocode-tui\";\nimport { getKeybindings, matchesKey, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { formatDurationSecs } from \"../../../core/format-duration.js\";\nimport type { Task, TaskAgent, TaskAgentKind, TaskAgentState, TaskStatus } from \"../../../core/task-store.js\";\nimport { taskOwnerId, taskStore } from \"../../../core/task-store.js\";\nimport type { ThemeColor } from \"../theme/theme.js\";\nimport { agentColorFor, theme } from \"../theme/theme.js\";\nimport { appKeyLabel, matchesAppKey, rawKeyHint } from \"./keybinding-hints.js\";\n\nconst TASK_STATUS_ICON: Record<TaskStatus, string> = {\n\t// Hollow = not started, matching the hollow-means-lighter logic of ◇ and the\n\t// selectors' ○/◉ convention; also keeps ● exclusive to the chat's tool\n\t// status dot so the two never read as the same signal.\n\tpending: \"○\",\n\tin_progress: \"◐\",\n\tdone: \"✓\",\n\tfailed: \"✗\",\n\tcancelled: \"⊘\",\n};\n\n/**\n * U+26A0 with VS15 (text presentation). Bare ⚠ carries the Unicode Emoji\n * property, and terminals with emoji font fallback render it as a double-width\n * color glyph while the width math counts 1 cell — misaligning exactly the\n * rows that carry a warning. VS15 forces single-cell text rendering and is\n * itself zero-width, so visibleWidth stays correct.\n */\nconst WARNING_GLYPH = \"⚠︎\";\n\n/** U+25B6 with VS15 — same emoji-presentation hazard as WARNING_GLYPH. */\nconst SELECTED_GLYPH = \"▶︎\";\n\n/**\n * Single-cell marker for MCP-sourced rows, which have no owning agent. Every\n * other row derives its marker from the owner's kind via AGENT_GLYPH (◆ main /\n * ◇ subagent / ▸ team role), so the flat lens attributes a row exactly the way\n * the grouped lenses do. The row also carries a text origin tag before the\n * title (see formatTaskLine).\n */\nconst MCP_SOURCE_GLYPH = \"⧉\";\n\n/** Braille spinner frames + cadence, matched to the TUI Loader so the active\n * row animates in step. Every tick re-renders the whole component tree, so the\n * cadence is a direct tax on long sessions — 120ms stays smooth while cutting\n * a third of the render load the old 80ms cadence generated. */\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nconst SPINNER_INTERVAL_MS = 120;\n\n/** A thin colored left rail groups the pane without a box, the way the design's `border-left` does. */\nconst RAIL = \"▎\";\n\n/** Cells in the deterministic progress bar (matches the design's 14-cell track). */\nconst PROGRESS_CELLS = 14;\n\n/**\n * How the same task list is presented:\n * - flat → one ungrouped list (default)\n * - subagents → grouped by owning agent (◆ main orchestrator + ◇ workers)\n * - teams → grouped by named role-agent (▸), with handoff arrows\n */\nexport type TaskPanelView = \"flat\" | \"subagents\" | \"teams\";\n\nconst VIEW_LABEL: Record<TaskPanelView, string> = { flat: \"tasks\", subagents: \"subagents\", teams: \"teams\" };\n\n/**\n * A top-level task the main agent owns directly: its own TodoWrite plan. Source\n * is unset (not a subagent/MCP delegation) and it sits at the root of the forest\n * (not a child merged in from a subagent's subtree). These are exactly the rows\n * the flat (\"tasks\") lens shows.\n */\nfunction isMainTask(task: Task): boolean {\n\t// Source-unset (not a subagent/MCP delegation — note taskOwnerId folds MCP into\n\t// \"main\", so check source directly), not owned by a role agent, and a forest\n\t// root (not a child merged in from a subagent's subtree).\n\treturn task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;\n}\n\n/**\n * Lenses that currently have content, split by ownership:\n * - flat (\"tasks\") → only when the main agent has its own TodoWrite plan.\n * - subagents → when delegated work exists (a registered subagent, or any\n * subagent/MCP-sourced task).\n * - teams → when role agents are registered (hooteams `--team`).\n * The cycle key and the header switcher both skip empty lenses, and an empty\n * flat lens falls through to subagents (see render), so a session that only\n * delegated work opens straight on the task tree with no empty \"tasks\" view.\n */\nfunction availableViews(tasks: readonly Task[], agents: readonly TaskAgent[]): TaskPanelView[] {\n\tconst views: TaskPanelView[] = [];\n\tif (tasks.some(isMainTask)) views.push(\"flat\");\n\tconst hasSubagentWork =\n\t\tagents.some((a) => a.kind === \"subagent\") || tasks.some((t) => t.source === \"subagent\" || t.source === \"mcp\");\n\tif (hasSubagentWork) views.push(\"subagents\");\n\tif (agents.some((a) => a.kind === \"role\")) views.push(\"teams\");\n\treturn views;\n}\n\n/** Owner glyphs: main agent a filled diamond, spawned subagents the hollow counterpart, team roles a triangle. */\nconst AGENT_GLYPH: Record<TaskAgentKind, string> = { main: \"◆\", subagent: \"◇\", role: \"▸\" };\nconst AGENT_GLYPH_COLOR: Record<TaskAgentKind, ThemeColor> = {\n\tmain: \"accent\",\n\tsubagent: \"accent\",\n\trole: \"borderAccent\",\n};\n\n/** Color for an agent's lifecycle `[state]` tag (mirrors the design's .ast-* classes). */\nconst AGENT_STATE_COLOR: Record<TaskAgentState, ThemeColor> = {\n\tactive: \"warning\",\n\trunning: \"warning\",\n\tdone: \"success\",\n\tqueued: \"dim\",\n\tidle: \"dim\",\n\twaiting: \"mdLink\",\n\tfailed: \"error\",\n\t// User-initiated stop: settled and intentional, so muted rather than red.\n\tcancelled: \"dim\",\n};\n\n/** Two-cell indent under a group header, with a faint vertical guide. */\nconst GROUP_INDENT_PLAIN = \"│ \";\n\n/** Overall pane state, derived from the task statuses. Drives the rail color + header stamp. */\ntype PanelState = \"working\" | \"reviewed\" | \"stopped\";\n\ninterface StatePresentation {\n\treadonly icon: string;\n\treadonly label: string;\n\treadonly color: \"warning\" | \"success\" | \"error\";\n}\n\nconst STATE_PRESENTATION: Record<PanelState, StatePresentation> = {\n\tworking: { icon: \"◐\", label: \"working\", color: \"warning\" },\n\treviewed: { icon: \"✓\", label: \"reviewed\", color: \"success\" },\n\tstopped: { icon: \"✗\", label: \"stopped\", color: \"error\" },\n};\n\nfunction panelState(tasks: readonly Task[]): PanelState {\n\tif (tasks.some((t) => t.status === \"failed\")) return \"stopped\";\n\tconst active = tasks.some((t) => t.status === \"in_progress\" || t.status === \"pending\");\n\treturn active ? \"working\" : \"reviewed\";\n}\n\nfunction taskStatusColor(status: TaskStatus): \"dim\" | \"warning\" | \"success\" | \"error\" {\n\tswitch (status) {\n\t\tcase \"in_progress\":\n\t\t\treturn \"warning\";\n\t\tcase \"done\":\n\t\t\treturn \"success\";\n\t\tcase \"failed\":\n\t\t\treturn \"error\";\n\t\tdefault:\n\t\t\t// pending and cancelled: quiet gray — a user-initiated cancel is not an\n\t\t\t// error and must not paint the row (or the rail) red.\n\t\t\treturn \"dim\";\n\t}\n}\n\n/**\n * Wall-clock time a task occupied. A settled task reads its create→final-update\n * span; a task still in progress is measured against the clock so its elapsed\n * time advances while it runs (updatedAt barely moves during a run, which used\n * to freeze the display at ~0s until completion).\n */\nfunction taskElapsedSecs(task: Task, now: number = Date.now()): number {\n\tconst end = task.status === \"in_progress\" ? now : task.updatedAt;\n\treturn Math.max(0, (end - task.createdAt) / 1000);\n}\n\n/**\n * Wall-clock span of the whole visible batch: earliest creation → now (while\n * anything is still running) or the latest settle time. NOT the sum of\n * per-task spans — summing made the header clock tick at 2× with two\n * concurrent subagents and 3× with three, a nonsense \"double time\" display.\n * Per-run timers live on the individual rows.\n */\nfunction turnElapsedSecs(tasks: readonly Task[], now: number = Date.now()): number {\n\tif (tasks.length === 0) return 0;\n\tlet earliest = Number.POSITIVE_INFINITY;\n\tlet latestEnd = 0;\n\tlet anyRunning = false;\n\tfor (const task of tasks) {\n\t\tearliest = Math.min(earliest, task.createdAt);\n\t\tif (task.status === \"in_progress\") anyRunning = true;\n\t\telse latestEnd = Math.max(latestEnd, task.updatedAt);\n\t}\n\tconst end = anyRunning ? now : latestEnd;\n\treturn Math.max(0, (end - earliest) / 1000);\n}\n\n/** The agent-type key a roster entry colors by: \"explore#2\" → \"explore\". */\nfunction agentTypeOfName(name: string): string {\n\tconst idx = name.indexOf(\"#\");\n\treturn idx > 0 ? name.slice(0, idx) : name;\n}\n\n/** Sum the token + cost usage reported by the tasks shown this turn. */\nfunction sumTurnUsage(tasks: readonly Task[]): { input: number; output: number; cost: number } | null {\n\tlet input = 0;\n\tlet output = 0;\n\tlet cost = 0;\n\tfor (const task of tasks) {\n\t\tif (!task.usage) continue;\n\t\tinput += task.usage.input;\n\t\toutput += task.usage.output;\n\t\tcost += task.usage.cost;\n\t}\n\tif (input === 0 && output === 0 && cost === 0) return null;\n\treturn { input, output, cost };\n}\n\n/**\n * Deterministic block-glyph progress bar: a heavy run (━) for the completed\n * fraction over a dim track. In-progress tasks count as half, so the bar moves\n * the moment work starts. Fraction is the only input — no animation, no guess.\n */\nfunction progressBar(done: number, active: number, total: number): { plain: string; styled: string } {\n\tconst ratio = total > 0 ? Math.max(0, Math.min(1, (done + active * 0.5) / total)) : 0;\n\tconst filled = Math.round(ratio * PROGRESS_CELLS);\n\tconst fill = \"━\".repeat(filled);\n\tconst track = \"━\".repeat(PROGRESS_CELLS - filled);\n\treturn {\n\t\tplain: fill + track,\n\t\tstyled: theme.fg(\"success\", fill) + theme.fg(\"dim\", track),\n\t};\n}\n\n/**\n * View switcher rendered at the right edge of the ledger header: the labels\n * of the lenses that have content joined by `·`, the active one in bold\n * accent. Hidden entirely when only one lens is available. Purely an\n * indicator in the TUI — the bound key cycles it (see app.tasks.cycleView).\n */\nfunction formatViewSwitcher(\n\tview: TaskPanelView,\n\tavailable: readonly TaskPanelView[],\n): { plain: string; styled: string } {\n\tif (available.length < 2) return { plain: \"\", styled: \"\" };\n\tconst plain = available.map((v) => VIEW_LABEL[v]).join(\" · \");\n\tconst styled = available\n\t\t.map((v) => (v === view ? theme.bold(theme.fg(\"accent\", VIEW_LABEL[v])) : theme.fg(\"dim\", VIEW_LABEL[v])))\n\t\t.join(theme.fg(\"dim\", \" · \"));\n\treturn { plain, styled };\n}\n\n/**\n * Ledger header: a state stamp (◐ working / ✓ reviewed / ✗ stopped) + a\n * deterministic progress bar and done/total count on the left, and the per-turn\n * token + elapsed + cost delta (summed across the tasks below) plus the view\n * switcher on the right.\n */\nfunction formatHeader(\n\ttasks: readonly Task[],\n\twidth: number,\n\tstate: PanelState,\n\ttotalSecs: number,\n\tview: TaskPanelView,\n\tavailable: readonly TaskPanelView[],\n): string {\n\tconst total = tasks.length;\n\tconst done = tasks.filter((t) => t.status === \"done\").length;\n\tconst active = tasks.filter((t) => t.status === \"in_progress\").length;\n\n\tconst { icon, label, color } = STATE_PRESENTATION[state];\n\tconst stampPlain = `${icon} ${label.toUpperCase()}`;\n\tconst stamp = `${theme.fg(color, icon)} ${theme.bold(theme.fg(color, label.toUpperCase()))}`;\n\n\tconst bar = progressBar(done, active, total);\n\tconst countPlain = `${done}/${total}`;\n\tconst count = theme.fg(\"muted\", `${done}`) + theme.fg(\"dim\", \"/\") + theme.fg(\"muted\", `${total}`);\n\n\t// Left cluster has a full form (stamp · bar · count) and a compact fallback\n\t// (stamp · count) that drops the bar when the terminal is too narrow.\n\tconst leftFullPlain = `${stampPlain} ${bar.plain} ${countPlain}`;\n\tconst leftFull = `${stamp} ${bar.styled} ${count}`;\n\tconst leftMinPlain = `${stampPlain} ${countPlain}`;\n\tconst leftMin = `${stamp} ${count}`;\n\n\tconst turn = sumTurnUsage(tasks);\n\tlet turnPlain = \"\";\n\tlet turnText = \"\";\n\tif (turn) {\n\t\tconst inTok = formatTokens(turn.input);\n\t\tconst outTok = formatTokens(turn.output);\n\t\tconst elapsed = formatDurationSecs(totalSecs);\n\t\tconst showCost = turn.cost > 0;\n\t\tconst costStr = showCost ? `$${turn.cost.toFixed(3)}` : \"\";\n\t\tturnPlain = `turn ↑${inTok} ↓${outTok} · ${elapsed}${showCost ? ` · ${costStr}` : \"\"}`;\n\t\t// Turn delta: muted framing, numbers one step brighter (bold), separators dim.\n\t\tturnText =\n\t\t\ttheme.fg(\"muted\", \"turn ↑\") +\n\t\t\ttheme.bold(inTok) +\n\t\t\ttheme.fg(\"muted\", \" ↓\") +\n\t\t\ttheme.bold(outTok) +\n\t\t\ttheme.fg(\"dim\", \" · \") +\n\t\t\ttheme.fg(\"muted\", elapsed) +\n\t\t\t(showCost ? theme.fg(\"dim\", \" · \") + theme.bold(costStr) : \"\");\n\t}\n\n\t// Right cluster: turn delta, then the view switcher at the far edge. The\n\t// switcher is the first thing dropped when the terminal narrows; the turn\n\t// delta next; the stamp/count survive to the end. Either piece may be\n\t// absent (no usage reported / only one lens available).\n\tconst switcher = formatViewSwitcher(view, available);\n\tconst rightVariants: Array<{ plain: string; styled: string }> = [];\n\tif (turnPlain && switcher.plain) {\n\t\trightVariants.push({\n\t\t\tplain: `${turnPlain} ${switcher.plain}`,\n\t\t\tstyled: `${turnText} ${switcher.styled}`,\n\t\t});\n\t}\n\tif (turnPlain) rightVariants.push({ plain: turnPlain, styled: turnText });\n\telse if (switcher.plain) rightVariants.push(switcher);\n\n\tfor (const right of rightVariants) {\n\t\tif (visibleWidth(leftFullPlain) + 2 + visibleWidth(right.plain) <= width) {\n\t\t\tconst pad = Math.max(2, width - visibleWidth(leftFullPlain) - visibleWidth(right.plain));\n\t\t\treturn leftFull + \" \".repeat(pad) + right.styled;\n\t\t}\n\t\tif (visibleWidth(leftMinPlain) + 2 + visibleWidth(right.plain) <= width) {\n\t\t\tconst pad = Math.max(2, width - visibleWidth(leftMinPlain) - visibleWidth(right.plain));\n\t\t\treturn leftMin + \" \".repeat(pad) + right.styled;\n\t\t}\n\t}\n\tif (visibleWidth(leftFullPlain) <= width) {\n\t\treturn leftFull + \" \".repeat(width - visibleWidth(leftFullPlain));\n\t}\n\treturn truncateToWidth(leftMin, width, \"…\");\n}\n\nfunction formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\treturn `${(count / 1000000).toFixed(1)}M`;\n}\n\nfunction formatTaskLine(\n\ttask: Task,\n\twidth: number,\n\tframe: number,\n\toptions: { grouped?: boolean; owner?: TaskAgent; treePrefix?: string } = {},\n): string {\n\tconst isProgress = task.status === \"in_progress\";\n\tconst iconGlyph = isProgress\n\t\t? (SPINNER_FRAMES[frame] ?? TASK_STATUS_ICON.in_progress)\n\t\t: TASK_STATUS_ICON[task.status];\n\tconst icon = theme.fg(taskStatusColor(task.status), iconGlyph);\n\n\t// In grouped views the group header already carries the row's origin, so the\n\t// owner glyph and tag are suppressed; the rows sit on a faint indent guide.\n\tconst grouped = options.grouped === true;\n\tconst indent = grouped ? theme.fg(\"borderMuted\", GROUP_INDENT_PLAIN) : \"\";\n\n\t// Owner marker between the status icon and the id, derived from the owning\n\t// agent's kind so the flat lens attributes rows the same way the grouped\n\t// lenses do (a roster-less owner falls back on the task's source). MCP rows\n\t// have no owning agent and keep their own ⧉ marker. Main-owned rows carry NO\n\t// glyph in the flat lens — the pane's rail already says \"main\", so a ◆ on\n\t// every plan row was pure noise; the glyph appears only where it\n\t// disambiguates (subagent runs, MCP calls, mixed trees).\n\tconst isMcp = task.source === \"mcp\";\n\tconst ownerKind = options.owner?.kind ?? (task.source === \"subagent\" ? \"subagent\" : \"main\");\n\tconst showOwnerGlyph = !grouped && (isMcp || ownerKind !== \"main\");\n\tconst sourceGlyph = isMcp ? MCP_SOURCE_GLYPH : AGENT_GLYPH[ownerKind];\n\t// Subagent rows carry their agent's identity color on the glyph (and tag\n\t// below) — the same hue as the chat's `Agent [type]` line and TaskOutput —\n\t// so a user can trace one agent across the whole TUI at a glance.\n\tconst agentTypeName = !isMcp && ownerKind === \"subagent\" ? (task.subagentMode ?? task.agent) : undefined;\n\tconst styledSource = !showOwnerGlyph\n\t\t? \"\"\n\t\t: agentTypeName\n\t\t\t? theme.fg(agentColorFor(agentTypeName), sourceGlyph)\n\t\t\t: theme.fg(\"dim\", sourceGlyph);\n\n\t// Origin tag prefixed to the title, naming who runs the row: the subagent\n\t// type (\"[explore]\"), the team role's name (\"[planner]\"), or the MCP server\n\t// (\"[github]\"; \"[MCP]\" when no server label was recorded). Drawn in accent,\n\t// parallel to the chat's `Agent [explore]` / `MCP [server › tool]`. Grouped\n\t// rows drop it — the group header carries the origin — except MCP rows,\n\t// which group under main without being main's own work.\n\tlet tag = \"\";\n\tlet tagColor: ThemeColor = \"accent\";\n\tif (isMcp) tag = `[${task.subagentMode ?? \"MCP\"}]`;\n\telse if (!grouped) {\n\t\tif (task.subagentMode) {\n\t\t\ttag = `[${task.subagentMode}]`;\n\t\t\ttagColor = agentColorFor(task.subagentMode);\n\t\t} else if (ownerKind === \"role\" && options.owner) {\n\t\t\ttag = `[${options.owner.name}]`;\n\t\t\ttagColor = agentColorFor(options.owner.name);\n\t\t}\n\t}\n\tconst styledTag = tag ? `${theme.fg(tagColor, tag)} ` : \"\";\n\tconst title = task.title;\n\t// The title carries the line. Done titles fade to muted\n\t// (settled work), pending dim (not started), active goes bold, failed turns red.\n\tlet styledTitle: string;\n\tswitch (task.status) {\n\t\tcase \"done\":\n\t\t\tstyledTitle = theme.fg(\"muted\", title);\n\t\t\tbreak;\n\t\tcase \"pending\":\n\t\t\tstyledTitle = theme.fg(\"dim\", title);\n\t\t\tbreak;\n\t\tcase \"failed\":\n\t\t\tstyledTitle = theme.fg(\"error\", title);\n\t\t\tbreak;\n\t\tcase \"cancelled\":\n\t\t\tstyledTitle = theme.fg(\"dim\", theme.strikethrough(title));\n\t\t\tbreak;\n\t\tcase \"in_progress\":\n\t\t\tstyledTitle = theme.bold(title);\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tstyledTitle = title;\n\t}\n\n\t// Right column: settled rows carry their token usage; the\n\t// active row reads `running…`, pending rows read `queued`.\n\tlet rightPlain = \"\";\n\tlet rightStyled = \"\";\n\tif (task.status === \"done\" || task.status === \"failed\") {\n\t\tlet tokenText = \"\";\n\t\tif (task.usage) {\n\t\t\tconst totalTok = task.usage.input + task.usage.output;\n\t\t\tif (totalTok > 0) tokenText = formatTokens(totalTok);\n\t\t}\n\t\tif (tokenText) {\n\t\t\trightPlain = tokenText;\n\t\t\trightStyled = theme.fg(\"muted\", tokenText);\n\t\t}\n\t} else if (task.status === \"cancelled\") {\n\t\trightPlain = \"cancelled\";\n\t\trightStyled = theme.fg(\"dim\", rightPlain);\n\t} else if (task.status === \"in_progress\") {\n\t\t// Surface the owning agent's live activity (the tool it's currently running,\n\t\t// fed by the pool's task_progress events) so a delegated row reads \"⋯ grep\"\n\t\t// rather than a static \"running…\" — the difference between looking busy and\n\t\t// looking stuck. Falls back to \"running…\" between tools (activity cleared) or\n\t\t// when no owner is resolved. Each running row carries its own live timer —\n\t\t// the per-run clock the header (a single wall-clock span) deliberately\n\t\t// doesn't show. Ticks smoothly because active rows re-render per spinner\n\t\t// frame anyway.\n\t\tconst liveActivity = options.owner?.activity;\n\t\tconst runFor = formatDurationSecs(taskElapsedSecs(task));\n\t\trightPlain = `${liveActivity ? `⋯ ${liveActivity}` : \"running…\"} · ${runFor}`;\n\t\trightStyled =\n\t\t\ttheme.fg(\"warning\", liveActivity ? `⋯ ${liveActivity}` : \"running…\") + theme.fg(\"dim\", ` · ${runFor}`);\n\t} else if (task.status === \"pending\") {\n\t\trightPlain = \"queued\";\n\t\trightStyled = theme.fg(\"dim\", rightPlain);\n\t}\n\n\t// A warning note (e.g. inherited-model fallback, exhaustion skip) takes over the\n\t// right column as a ⚠ cue, replacing the usage/status stamp for that row.\n\tif (task.note) {\n\t\trightPlain = `${WARNING_GLYPH} ${task.note}`;\n\t\trightStyled = theme.fg(\"warning\", rightPlain);\n\t}\n\n\tconst rightWidth = rightPlain ? visibleWidth(rightPlain) + 1 : 0;\n\tconst leftWidth = Math.max(0, width - rightWidth);\n\n\t// truncateToWidth measures visible width (ANSI-aware), so the styled left can be\n\t// truncated against the full left budget directly. Subtracting the prefix here\n\t// (as a prior version did) truncated titles early and unevenly per id width.\n\t// In the subagents tree, a depth-first connector prefix (└─/├─/│) sits before\n\t// the row's glyph; roots pass an empty prefix and read exactly like flat rows.\n\tconst treePrefix = options.treePrefix ? theme.fg(\"borderMuted\", options.treePrefix) : \"\";\n\tconst leftBody = grouped\n\t\t? `${indent}${icon} ${styledTag}${styledTitle}`\n\t\t: `${treePrefix}${icon} ${styledSource ? `${styledSource} ` : \"\"}${styledTag}${styledTitle}`;\n\tconst left = truncateToWidth(leftBody, leftWidth, \"…\");\n\n\t// Pad every row to the full pane width so rows align regardless of whether they\n\t// carry a right column (token usage / running… / queued).\n\tif (!rightPlain) {\n\t\tconst pad = Math.max(0, width - visibleWidth(left));\n\t\treturn left + \" \".repeat(pad);\n\t}\n\n\tconst pad = Math.max(1, width - visibleWidth(left) - visibleWidth(rightPlain));\n\treturn left + \" \".repeat(pad) + rightStyled;\n}\n\n/**\n * Filter tasks and agents to the teams lens: only role agents and the tasks they\n * own. The flat and subagents lenses do their own ownership filtering inline (by\n * source and parentTaskId), so this is teams-specific.\n */\nfunction filterTasksForView(\n\ttasks: readonly Task[],\n\tagents: readonly TaskAgent[],\n\t_view: TaskPanelView,\n): { filteredTasks: readonly Task[]; filteredAgents: readonly TaskAgent[] } {\n\tconst roleIds = new Set(agents.filter((a) => a.kind === \"role\").map((a) => a.id));\n\treturn {\n\t\tfilteredAgents: agents.filter((a) => a.kind === \"role\"),\n\t\tfilteredTasks: tasks.filter((t) => roleIds.has(taskOwnerId(t))),\n\t};\n}\n\n/** One renderable row of the subagents lens: a task plus its tree connector. */\ninterface SubagentTreeRow {\n\ttask: Task;\n\ttreePrefix: string;\n}\n\n/**\n * The subagents lens's rows in render order: a depth-first walk of the\n * delegated forest. Roots are parentless subagent/MCP tasks, plus orphans —\n * children whose parent was dropped from the store (their `parentTaskId` no\n * longer resolves), which used to be counted by the header but never rendered.\n * A visited set guards against `parentTaskId` cycles, so a corrupt link can\n * never hang the render loop. Both the lens filter (header counts) and the\n * renderer consume this list, so count and rendering can never disagree.\n */\nfunction subagentLensRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\tconst ids = new Set<number>();\n\tfor (const task of tasks) ids.add(task.id);\n\tconst childrenByParent = new Map<number, Task[]>();\n\tfor (const task of tasks) {\n\t\tif (task.parentTaskId === undefined) continue;\n\t\tconst siblings = childrenByParent.get(task.parentTaskId);\n\t\tif (siblings) siblings.push(task);\n\t\telse childrenByParent.set(task.parentTaskId, [task]);\n\t}\n\tconst roots = tasks.filter(\n\t\t(t) =>\n\t\t\t(t.parentTaskId === undefined && (t.source === \"subagent\" || t.source === \"mcp\")) ||\n\t\t\t(t.parentTaskId !== undefined && !ids.has(t.parentTaskId)),\n\t);\n\n\tconst rows: SubagentTreeRow[] = [];\n\tconst visited = new Set<number>();\n\tconst walk = (task: Task, prefix: string, isLast: boolean, isRoot: boolean): void => {\n\t\tif (visited.has(task.id)) return;\n\t\tvisited.add(task.id);\n\t\trows.push({ task, treePrefix: isRoot ? \"\" : `${prefix}${isLast ? \"└─ \" : \"├─ \"}` });\n\t\tconst kids = childrenByParent.get(task.id) ?? [];\n\t\tconst childPrefix = isRoot ? \"\" : `${prefix}${isLast ? \" \" : \"│ \"}`;\n\t\tfor (let i = 0; i < kids.length; i++) {\n\t\t\twalk(kids[i] as Task, childPrefix, i === kids.length - 1, false);\n\t\t}\n\t};\n\tfor (const root of roots) walk(root, \"\", true, true);\n\treturn rows;\n}\n\n/**\n * The flat (\"tasks\") lens's rows in render order: the main agent's TodoWrite\n * plan, with each subagent run nested (└─/├─) under the plan item it was\n * dispatched for (`linkedTaskId`, recorded when exactly one item was\n * in_progress). This is the visual tie between the plan and the agents\n * executing it — previously a spawn was linked in spirit but invisible here,\n * living only in the subagents lens. Runs with no link (or a dangling one —\n * their todo was replaced) stay out of this lens; they remain visible in the\n * subagents tree.\n */\nfunction flatLensRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\tconst linkedByTodo = new Map<number, Task[]>();\n\tfor (const task of tasks) {\n\t\tif (task.linkedTaskId === undefined || task.source !== \"subagent\") continue;\n\t\tconst siblings = linkedByTodo.get(task.linkedTaskId);\n\t\tif (siblings) siblings.push(task);\n\t\telse linkedByTodo.set(task.linkedTaskId, [task]);\n\t}\n\tconst rows: SubagentTreeRow[] = [];\n\tfor (const task of tasks) {\n\t\tif (!isMainTask(task)) continue;\n\t\trows.push({ task, treePrefix: \"\" });\n\t\tconst linked = linkedByTodo.get(task.id) ?? [];\n\t\tfor (let i = 0; i < linked.length; i++) {\n\t\t\trows.push({ task: linked[i] as Task, treePrefix: i === linked.length - 1 ? \"└─ \" : \"├─ \" });\n\t\t}\n\t}\n\treturn rows;\n}\n\n/**\n * Scope the full task list to the tasks visible in the given lens. The header,\n * state stamp, and done/total count are derived from this subset so they match\n * exactly what the user sees in the current view.\n */\nfunction filterTasksForLens(\n\ttasks: readonly Task[],\n\tagents: readonly TaskAgent[],\n\tview: TaskPanelView,\n): readonly Task[] {\n\tswitch (view) {\n\t\tcase \"flat\":\n\t\t\t// Same walk the renderer uses (plan items + their linked runs), so the\n\t\t\t// header's done/total always matches the visible rows.\n\t\t\treturn flatLensRows(tasks).map((row) => row.task);\n\t\tcase \"subagents\":\n\t\t\t// Exactly the rows the tree renders (same walk), so the header's\n\t\t\t// done/total always matches the visible rows.\n\t\t\treturn subagentLensRows(tasks).map((row) => row.task);\n\t\tcase \"teams\": {\n\t\t\tconst roleIds = new Set<string>();\n\t\t\tfor (const a of agents) {\n\t\t\t\tif (a.kind === \"role\") roleIds.add(a.id);\n\t\t\t}\n\t\t\treturn tasks.filter((t) => roleIds.has(taskOwnerId(t)));\n\t\t}\n\t}\n}\n\n/** Fallback group metadata when a task's owner has no roster entry. */\nfunction defaultAgentMeta(id: string): TaskAgent {\n\treturn id === \"main\"\n\t\t? { id, name: \"main\", role: \"orchestrator\", kind: \"main\" }\n\t\t: { id, name: id, role: \"subagent\", kind: \"subagent\" };\n}\n\n/**\n * Partition the flat task list into owner groups. An explicit task.agent wins;\n * otherwise a subagent-sourced task falls into a generic \"subagent\" group and\n * everything else into \"main\". Group order is deterministic — main first, then\n * roster order, then stragglers — never reordered by status.\n */\nfunction groupTasks(\n\ttasks: readonly Task[],\n\tagents: readonly TaskAgent[],\n): Array<{ id: string; meta: TaskAgent; items: Task[] }> {\n\tconst meta = new Map<string, TaskAgent>(agents.map((a) => [a.id, a]));\n\tconst groups = new Map<string, Task[]>();\n\tfor (const task of tasks) {\n\t\tconst owner = taskOwnerId(task);\n\t\tconst items = groups.get(owner);\n\t\tif (items) items.push(task);\n\t\telse groups.set(owner, [task]);\n\t}\n\tconst order: string[] = [];\n\tconst ordered = new Set<string>();\n\tconst push = (id: string) => {\n\t\tif (!ordered.has(id)) {\n\t\t\tordered.add(id);\n\t\t\torder.push(id);\n\t\t}\n\t};\n\tif (groups.has(\"main\")) push(\"main\");\n\tfor (const agent of agents) {\n\t\tif (groups.has(agent.id)) push(agent.id);\n\t}\n\tfor (const id of groups.keys()) push(id);\n\treturn order.map((id) => ({\n\t\tid,\n\t\tmeta: meta.get(id) ?? defaultAgentMeta(id),\n\t\titems: groups.get(id) ?? [],\n\t}));\n}\n\n/**\n * Group header for the grouped views: owner glyph + bold name + role, the\n * agent's lifecycle `[state]` tag, an optional handoff arrow (teams), then the\n * agent's own token/cost totals + done/total on the right. Mirrors the footer's\n * \"every number accounted for\" stance, but per agent.\n */\nfunction formatGroupHeader(meta: TaskAgent, items: readonly Task[], width: number, selected = false): string {\n\t// A focused role row swaps its ▸ for a filled ▶ in accent — the team-focus\n\t// selection cursor (the owner-kind glyph mapping itself is unchanged).\n\t// Spawned/team agents carry their identity color on the glyph and name\n\t// (hashed from the agent type, \"explore#2\" → \"explore\"), matching the same\n\t// agent's color in the chat and TaskOutput; main keeps the accent.\n\tconst identityColor: ThemeColor =\n\t\tmeta.kind === \"main\" ? AGENT_GLYPH_COLOR[meta.kind] : agentColorFor(agentTypeOfName(meta.name));\n\tconst glyph = selected\n\t\t? theme.fg(\"accent\", SELECTED_GLYPH)\n\t\t: theme.fg(identityColor, AGENT_GLYPH[meta.kind] ?? AGENT_GLYPH.subagent);\n\tconst name = selected\n\t\t? theme.bold(theme.fg(\"accent\", meta.name))\n\t\t: meta.kind === \"main\"\n\t\t\t? theme.bold(meta.name)\n\t\t\t: theme.bold(theme.fg(identityColor, meta.name));\n\t// Roles read as a dim \"· role\" suffix for spawned/team agents; the main\n\t// orchestrator's role sits brighter (muted), matching the design's .grp-role.\n\tconst role = meta.role\n\t\t? meta.kind === \"main\"\n\t\t\t? ` ${theme.fg(\"muted\", meta.role)}`\n\t\t\t: theme.fg(\"dim\", ` · ${meta.role}`)\n\t\t: \"\";\n\tconst state = meta.state ? ` ${theme.fg(AGENT_STATE_COLOR[meta.state] ?? \"dim\", `[${meta.state}]`)}` : \"\";\n\t// Live activity for a running subagent (e.g. the tool it's executing). Empty\n\t// string means idle/between-tools, so it disappears rather than lingering stale.\n\tconst activity = meta.activity ? theme.fg(\"dim\", ` ⋯ ${meta.activity}`) : \"\";\n\tconst handoff = meta.handoff ? ` ${theme.fg(\"dim\", meta.handoff)}` : \"\";\n\n\tconst done = items.filter((t) => t.status === \"done\").length;\n\tconst countPlain = `${done}/${items.length}`;\n\tconst count = theme.fg(\"muted\", `${done}`) + theme.fg(\"dim\", \"/\") + theme.fg(\"muted\", `${items.length}`);\n\tconst stats = meta.stats;\n\tlet rightPlain = countPlain;\n\tlet rightStyled = count;\n\tif (stats && (stats.input > 0 || stats.output > 0 || stats.cost > 0)) {\n\t\tconst statsPlain = `↑${formatTokens(stats.input)} ↓${formatTokens(stats.output)} · $${stats.cost.toFixed(3)}`;\n\t\trightPlain = `${statsPlain} ${countPlain}`;\n\t\trightStyled = `${theme.fg(\"dim\", statsPlain)} ${count}`;\n\t}\n\n\tconst rightWidth = visibleWidth(rightPlain) + 1;\n\tconst leftWidth = Math.max(0, width - rightWidth);\n\tconst left = truncateToWidth(`${glyph} ${name}${role}${state}${activity}${handoff}`, leftWidth, \"…\");\n\tconst pad = Math.max(1, width - visibleWidth(left) - visibleWidth(rightPlain));\n\treturn left + \" \".repeat(pad) + rightStyled;\n}\n\n/**\n * Task panel rendered just above the editor prompt.\n *\n * - A state-colored left rail groups the pane (working=warning, reviewed=success,\n * stopped=error) without drawing a box.\n * - A ledger header tops the list: a state stamp + deterministic progress bar +\n * done/total count on the left, the per-turn token/elapsed/cost delta on the right.\n * - Shows all tasks with all statuses (pending / in_progress / done / failed).\n * The active row animates a braille spinner; pending rows read `queued`.\n * - A single-cell owner glyph (◇ subagent / ▸ team role / ⧉ MCP) sits before\n * the id where it disambiguates; main-owned rows carry no glyph in the flat\n * lens (the rail already attributes the pane to the main agent). A text\n * origin tag before the title names the owner: the subagent type\n * (\"[explore]\"), the team role (\"[planner]\", fed by `--team <url>`), or the\n * MCP server (\"[github]\").\n * - Three views split by ownership (cycled via app.tasks.cycleView, shown as a\n * `tasks · subagents · teams` switcher in the header):\n * - flat (\"tasks\") — the main agent's own TodoWrite plan, with each\n * dispatched subagent run nested under the plan item it was dispatched\n * for (linkedTaskId), so the plan and the agents executing it read as\n * one picture;\n * - subagents — a recursive task tree over delegated work, where a subagent\n * that spawned a subagent shows its nested tasks (roots are the dispatched\n * subagents and direct MCP calls; children link via parentTaskId, merged\n * across the process boundary). Each task is its own node keeping its\n * [subagentMode]/[server] tag, with depth drawn by └─/├─/│ connectors; a\n * run with only top-level tasks reads flat (no extra indent);\n * - teams — grouped by named role-agent with handoff arrows.\n * The cycle is adaptive: empty lenses are skipped and dropped from the\n * switcher, which hides entirely when only one lens has content. An empty flat\n * lens (no main task) falls through to subagents when delegated work exists.\n * - LIFO within the window: newest tasks appear at the bottom (closest to the prompt).\n * - Finished tasks carry their wall-clock cost and stay visible until the next\n * user message arrives (see taskStore.reset()), not the moment they finish.\n * - Collapses to zero lines when there are no tasks — unless a team roster is\n * registered (`--team`), in which case the empty flat lens falls through to\n * teams and every role renders as a placeholder group, so idle roles are\n * visible from startup.\n */\nexport class TaskPanelComponent implements Component, Focusable {\n\tprivate readonly ui: TUI | null;\n\tprivate frame = 0;\n\tprivate animationTimer: ReturnType<typeof setInterval> | null = null;\n\tprivate view: TaskPanelView = \"flat\";\n\tprivate disposed = false;\n\n\t// Render memoization. The spinner re-renders the whole pane every 80ms, but\n\t// between store mutations only the in-progress rows actually change (spinner\n\t// frame + live activity). Settled/pending row strings and derived structures\n\t// (agentById, the subagents tree walk) are cached and invalidated by the\n\t// store's mutation version + pane width, so a frame tick reuses them instead\n\t// of rebuilding every string.\n\tprivate memoVersion = -1;\n\tprivate memoWidth = -1;\n\tprivate lineMemo = new Map<string, string>();\n\tprivate agentByIdMemo: Map<string, TaskAgent> | null = null;\n\tprivate subagentRowsMemo: SubagentTreeRow[] | null = null;\n\tprivate flatRowsMemo: SubagentTreeRow[] | null = null;\n\n\t/** Drop all memoized render state when the store or width changed. */\n\tprivate syncMemo(width: number): void {\n\t\tconst version = taskStore.version();\n\t\tif (version === this.memoVersion && width === this.memoWidth) return;\n\t\tthis.memoVersion = version;\n\t\tthis.memoWidth = width;\n\t\tthis.lineMemo.clear();\n\t\tthis.agentByIdMemo = null;\n\t\tthis.subagentRowsMemo = null;\n\t\tthis.flatRowsMemo = null;\n\t}\n\n\tprivate agentById(agents: readonly TaskAgent[]): Map<string, TaskAgent> {\n\t\tif (!this.agentByIdMemo) {\n\t\t\tthis.agentByIdMemo = new Map(agents.map((a) => [a.id, a]));\n\t\t}\n\t\treturn this.agentByIdMemo;\n\t}\n\n\tprivate subagentRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\t\tif (!this.subagentRowsMemo) {\n\t\t\tthis.subagentRowsMemo = subagentLensRows(tasks);\n\t\t}\n\t\treturn this.subagentRowsMemo;\n\t}\n\n\tprivate flatRows(tasks: readonly Task[]): SubagentTreeRow[] {\n\t\tif (!this.flatRowsMemo) {\n\t\t\tthis.flatRowsMemo = flatLensRows(tasks);\n\t\t}\n\t\treturn this.flatRowsMemo;\n\t}\n\n\t/**\n\t * formatTaskLine with caching for rows that don't animate. In-progress rows\n\t * depend on the spinner frame and the owner's live activity, so they render\n\t * fresh every frame; everything else is stable until the next store mutation\n\t * or width change (handled by syncMemo).\n\t */\n\tprivate taskLine(\n\t\ttask: Task,\n\t\twidth: number,\n\t\toptions: { grouped?: boolean; owner?: TaskAgent; treePrefix?: string } = {},\n\t): string {\n\t\tif (task.status === \"in_progress\") {\n\t\t\treturn formatTaskLine(task, width, this.frame, options);\n\t\t}\n\t\tconst key = `${task.id}:${options.grouped ? 1 : 0}:${options.treePrefix ?? \"\"}:${options.owner?.kind ?? \"\"}`;\n\t\tconst hit = this.lineMemo.get(key);\n\t\tif (hit !== undefined) return hit;\n\t\tconst line = formatTaskLine(task, width, this.frame, options);\n\t\tthis.lineMemo.set(key, line);\n\t\treturn line;\n\t}\n\n\t// Team focus mode: when the TUI focuses the panel, role rows become a\n\t// navigable list (↑/↓ select, n nudge, a attach, q/esc back). The selection\n\t// is tracked by role name so a roster reorder doesn't move the cursor.\n\tfocused = false;\n\tprivate selectedRole: string | undefined;\n\t/** Open the inline nudge editor for the selected role. */\n\tonNudge?: (role: string) => void;\n\t/** Open the attach side panel for the selected role. */\n\tonAttach?: (role: string) => void;\n\t/** Leave team focus (focus returns to the main editor). */\n\tonExitFocus?: () => void;\n\n\tconstructor(ui?: TUI) {\n\t\tthis.ui = ui ?? null;\n\t}\n\n\tinvalidate(): void {\n\t\t// Force the next render to rebuild its memoized rows/structures.\n\t\tthis.memoVersion = -1;\n\t}\n\n\tprivate roleAgents(): TaskAgent[] {\n\t\treturn taskStore.agents().filter((a) => a.kind === \"role\");\n\t}\n\n\t/** The role the team-focus cursor sits on (clamped to the live roster). */\n\tfocusedRole(): string | undefined {\n\t\tconst roles = this.roleAgents();\n\t\tif (roles.length === 0) return undefined;\n\t\tconst match = roles.find((a) => a.name === this.selectedRole);\n\t\treturn (match ?? roles[0]).name;\n\t}\n\n\thandleInput(data: string): void {\n\t\tconst roles = this.roleAgents();\n\t\tif (roles.length === 0) {\n\t\t\tthis.onExitFocus?.();\n\t\t\treturn;\n\t\t}\n\t\tconst keybindings = getKeybindings();\n\t\tconst index = Math.max(\n\t\t\t0,\n\t\t\troles.findIndex((a) => a.name === this.selectedRole),\n\t\t);\n\t\tif (keybindings.matches(data, \"tui.select.up\")) {\n\t\t\tthis.selectedRole = roles[Math.max(0, index - 1)].name;\n\t\t} else if (keybindings.matches(data, \"tui.select.down\")) {\n\t\t\tthis.selectedRole = roles[Math.min(roles.length - 1, index + 1)].name;\n\t\t} else if (matchesAppKey(data, \"app.team.nudge\")) {\n\t\t\tconst role = this.focusedRole();\n\t\t\tif (role) this.onNudge?.(role);\n\t\t} else if (matchesAppKey(data, \"app.team.attach\")) {\n\t\t\tconst role = this.focusedRole();\n\t\t\tif (role) this.onAttach?.(role);\n\t\t} else if (matchesKey(data, \"q\") || keybindings.matches(data, \"tui.select.cancel\")) {\n\t\t\tthis.onExitFocus?.();\n\t\t}\n\t\tthis.ui?.requestRender();\n\t}\n\n\tgetView(): TaskPanelView {\n\t\treturn this.view;\n\t}\n\n\tsetView(view: TaskPanelView): void {\n\t\tthis.view = view;\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Advance to the next view lens with content (flat → subagents → teams →\n\t * flat), skipping empty lenses. With nothing delegated this is a no-op on\n\t * flat; a stale view (its lens emptied since selection) snaps back to flat.\n\t */\n\tcycleView(): TaskPanelView {\n\t\tconst available = availableViews(taskStore.list(), taskStore.agents());\n\t\tconst idx = available.indexOf(this.view);\n\t\tthis.view = available[(idx + 1) % available.length] ?? \"flat\";\n\t\tthis.ui?.requestRender();\n\t\treturn this.view;\n\t}\n\n\t/** Run the spinner timer only while a task is active, ticking re-renders. */\n\tprivate ensureAnimation(active: boolean): void {\n\t\tif (this.disposed) {\n\t\t\tif (this.animationTimer) {\n\t\t\t\tclearInterval(this.animationTimer);\n\t\t\t\tthis.animationTimer = null;\n\t\t\t\tthis.frame = 0;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (active && this.ui && !this.animationTimer) {\n\t\t\tthis.animationTimer = setInterval(() => {\n\t\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\t\tthis.ui?.requestRender();\n\t\t\t}, SPINNER_INTERVAL_MS);\n\t\t\tthis.animationTimer.unref?.();\n\t\t} else if (!active && this.animationTimer) {\n\t\t\tclearInterval(this.animationTimer);\n\t\t\tthis.animationTimer = null;\n\t\t\tthis.frame = 0;\n\t\t}\n\t}\n\n\t/** Stop the spinner timer. Call on teardown. */\n\tdispose(): void {\n\t\tif (this.animationTimer) {\n\t\t\tclearInterval(this.animationTimer);\n\t\t\tthis.animationTimer = null;\n\t\t}\n\t\tthis.disposed = true;\n\t}\n\n\trender(width: number): string[] {\n\t\tif (this.disposed) return [];\n\n\t\tthis.syncMemo(width);\n\t\tconst tasks = taskStore.list();\n\t\tconst allAgents = taskStore.agents();\n\n\t\t// Resolve the lens: keep the stored view while it still has content, else\n\t\t// fall through to the first available lens (flat → subagents → teams). The\n\t\t// stored view is untouched so an explicit setView choice resumes once its\n\t\t// content returns. This is how an empty flat lens (no main/TodoWrite task)\n\t\t// falls through to the subagents tree when only delegated work exists, and\n\t\t// how an empty pane falls through to the teams roster at startup.\n\t\tconst available = availableViews(tasks, allAgents);\n\t\tlet view: TaskPanelView = available.includes(this.view) ? this.view : (available[0] ?? \"flat\");\n\n\t\t// Team focus always operates on the teams lens — the focused role list is\n\t\t// exactly what the lens renders, so the cursor is never invisible.\n\t\tif (this.focused) view = \"teams\";\n\n\t\t// In teams view the roster itself is content: role agents render as\n\t\t// placeholder groups even without tasks (idle roles at startup, queued\n\t\t// upcoming work).\n\t\tconst hasRoleRoster = view === \"teams\" && allAgents.some((a) => a.kind === \"role\");\n\n\t\tif (tasks.length === 0 && !hasRoleRoster) {\n\t\t\tthis.ensureAnimation(false);\n\t\t\treturn [];\n\t\t}\n\n\t\t// Scope all visual state to the tasks visible in the current lens so the\n\t\t// animation, rail color, and header count match exactly what the user sees.\n\t\t// The flat and subagents lenses derive from their memoized row walks so\n\t\t// filter and render share one computation (one source of truth for counts).\n\t\tconst lensTasks =\n\t\t\tview === \"subagents\"\n\t\t\t\t? this.subagentRows(tasks).map((row) => row.task)\n\t\t\t\t: view === \"flat\"\n\t\t\t\t\t? this.flatRows(tasks).map((row) => row.task)\n\t\t\t\t\t: filterTasksForLens(tasks, allAgents, view);\n\t\tconst hasActive = lensTasks.some((t) => t.status === \"in_progress\");\n\t\tthis.ensureAnimation(hasActive);\n\n\t\tconst state = panelState(lensTasks);\n\t\tconst totalSecs = turnElapsedSecs(lensTasks);\n\t\tconst railColor = STATE_PRESENTATION[state].color;\n\t\tconst gutter = `${theme.fg(railColor, RAIL)} `;\n\t\tconst inner = Math.max(0, width - visibleWidth(RAIL) - 1);\n\n\t\tconst lines: string[] = [gutter + formatHeader(lensTasks, inner, state, totalSecs, view, available)];\n\n\t\tif (view === \"flat\") {\n\t\t\t// The main agent's TodoWrite plan, with each dispatched run nested under\n\t\t\t// the plan item it executes (see flatLensRows). Resolve each row's owner\n\t\t\t// from the roster so the glyph/tag reflect the owning agent's kind and a\n\t\t\t// nested run shows its live activity + timer.\n\t\t\tconst agentById = this.agentById(allAgents);\n\t\t\tfor (const { task, treePrefix } of this.flatRows(tasks)) {\n\t\t\t\tlines.push(gutter + this.taskLine(task, inner, { treePrefix, owner: agentById.get(taskOwnerId(task)) }));\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (view === \"subagents\") {\n\t\t\t// A recursive task tree over the delegated forest: roots are the main\n\t\t\t// agent's dispatched subagents (and direct MCP calls), children are the\n\t\t\t// tasks they spawned in turn (linked by parentTaskId, merged across the\n\t\t\t// process boundary), so a subagent that spawned a subagent is visible.\n\t\t\t// Each task is its own node keeping its [subagentMode]/[server] tag; depth\n\t\t\t// is drawn with └─/├─/│ connectors (see subagentLensRows for orphan and\n\t\t\t// cycle handling). With only top-level tasks the roots carry an empty\n\t\t\t// prefix and read exactly like flat rows (no extra indent). Owners are\n\t\t\t// resolved so an in-progress row shows the run's live tool activity\n\t\t\t// (⋯ grep) instead of a static \"running…\".\n\t\t\tconst agentById = this.agentById(allAgents);\n\t\t\tfor (const { task, treePrefix } of this.subagentRows(tasks)) {\n\t\t\t\tlines.push(\n\t\t\t\t\tgutter +\n\t\t\t\t\t\tthis.taskLine(task, inner, {\n\t\t\t\t\t\t\ttreePrefix,\n\t\t\t\t\t\t\towner: agentById.get(taskOwnerId(task)),\n\t\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\t// teams view: role-agent groups with handoff connectors and queued placeholders.\n\t\tconst { filteredTasks, filteredAgents } = filterTasksForView(tasks, allAgents, view);\n\t\tconst groups = groupTasks(filteredTasks, filteredAgents);\n\t\tconst groupIds = new Set(groups.map((g) => g.id));\n\t\t// Role agents with no tasks still get a group header: idle roles are the\n\t\t// roster at startup, queued ones upcoming work, done/failed ones the\n\t\t// state they settled in after reset() dropped their tasks.\n\t\tfor (const agent of filteredAgents) {\n\t\t\tif (!groupIds.has(agent.id)) {\n\t\t\t\tgroups.push({ id: agent.id, meta: agent, items: [] });\n\t\t\t}\n\t\t}\n\t\tconst cursorRole = this.focused ? this.focusedRole() : undefined;\n\t\tfor (const group of groups) {\n\t\t\tconst selected = cursorRole !== undefined && group.meta.kind === \"role\" && group.meta.name === cursorRole;\n\t\t\tlines.push(gutter + formatGroupHeader(group.meta, group.items, inner, selected));\n\t\t\tfor (const task of group.items) {\n\t\t\t\tlines.push(gutter + this.taskLine(task, inner, { grouped: true }));\n\t\t\t}\n\t\t\t// Forward-handoff connector: emit \"└──→ name\" only for \"→ name\" arrows\n\t\t\t// (not back-references \"← name\"), and only when the target exists in the\n\t\t\t// visible role roster.\n\t\t\tconst { handoff } = group.meta;\n\t\t\tif (handoff) {\n\t\t\t\tconst arrowIdx = handoff.indexOf(\"→ \");\n\t\t\t\tif (arrowIdx !== -1) {\n\t\t\t\t\tconst nextName = handoff.slice(arrowIdx + 2).trim();\n\t\t\t\t\tif (filteredAgents.some((a) => a.name === nextName)) {\n\t\t\t\t\t\tconst connectorPrefix = `${GROUP_INDENT_PLAIN} └──→ `;\n\t\t\t\t\t\tconst connectorPad = Math.max(0, inner - visibleWidth(connectorPrefix) - visibleWidth(nextName));\n\t\t\t\t\t\tlines.push(\n\t\t\t\t\t\t\tgutter +\n\t\t\t\t\t\t\t\ttheme.fg(\"borderMuted\", connectorPrefix) +\n\t\t\t\t\t\t\t\ttheme.fg(\"dim\", nextName) +\n\t\t\t\t\t\t\t\t\" \".repeat(connectorPad),\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (this.focused) {\n\t\t\t// House hint style (see keybinding-hints): dim key + muted description,\n\t\t\t// muted · separators. Arrows and q/esc are literal by convention; the\n\t\t\t// nudge/attach keys resolve from the live keybinding config.\n\t\t\tconst sep = theme.fg(\"muted\", \" · \");\n\t\t\tconst hint = [\n\t\t\t\trawKeyHint(\"↑/↓\", \"select\"),\n\t\t\t\trawKeyHint(appKeyLabel(\"app.team.nudge\"), \"nudge\"),\n\t\t\t\trawKeyHint(appKeyLabel(\"app.team.attach\"), \"attach\"),\n\t\t\t\trawKeyHint(\"q/esc\", \"back\"),\n\t\t\t].join(sep);\n\t\t\tlines.push(gutter + truncateToWidth(hint, inner, \"…\"));\n\t\t}\n\t\treturn lines;\n\t}\n}\n"]}
|
|
@@ -300,16 +300,19 @@ function formatTaskLine(task, width, frame, options = {}) {
|
|
|
300
300
|
// Owner marker between the status icon and the id, derived from the owning
|
|
301
301
|
// agent's kind so the flat lens attributes rows the same way the grouped
|
|
302
302
|
// lenses do (a roster-less owner falls back on the task's source). MCP rows
|
|
303
|
-
// have no owning agent and keep their own ⧉ marker.
|
|
304
|
-
//
|
|
303
|
+
// have no owning agent and keep their own ⧉ marker. Main-owned rows carry NO
|
|
304
|
+
// glyph in the flat lens — the pane's rail already says "main", so a ◆ on
|
|
305
|
+
// every plan row was pure noise; the glyph appears only where it
|
|
306
|
+
// disambiguates (subagent runs, MCP calls, mixed trees).
|
|
305
307
|
const isMcp = task.source === "mcp";
|
|
306
308
|
const ownerKind = options.owner?.kind ?? (task.source === "subagent" ? "subagent" : "main");
|
|
309
|
+
const showOwnerGlyph = !grouped && (isMcp || ownerKind !== "main");
|
|
307
310
|
const sourceGlyph = isMcp ? MCP_SOURCE_GLYPH : AGENT_GLYPH[ownerKind];
|
|
308
311
|
// Subagent rows carry their agent's identity color on the glyph (and tag
|
|
309
312
|
// below) — the same hue as the chat's `Agent [type]` line and TaskOutput —
|
|
310
313
|
// so a user can trace one agent across the whole TUI at a glance.
|
|
311
314
|
const agentTypeName = !isMcp && ownerKind === "subagent" ? (task.subagentMode ?? task.agent) : undefined;
|
|
312
|
-
const styledSource =
|
|
315
|
+
const styledSource = !showOwnerGlyph
|
|
313
316
|
? ""
|
|
314
317
|
: agentTypeName
|
|
315
318
|
? theme.fg(agentColorFor(agentTypeName), sourceGlyph)
|
|
@@ -413,7 +416,7 @@ function formatTaskLine(task, width, frame, options = {}) {
|
|
|
413
416
|
const treePrefix = options.treePrefix ? theme.fg("borderMuted", options.treePrefix) : "";
|
|
414
417
|
const leftBody = grouped
|
|
415
418
|
? `${indent}${icon} ${styledTag}${styledTitle}`
|
|
416
|
-
: `${treePrefix}${icon} ${styledSource} ${styledTag}${styledTitle}`;
|
|
419
|
+
: `${treePrefix}${icon} ${styledSource ? `${styledSource} ` : ""}${styledTag}${styledTitle}`;
|
|
417
420
|
const left = truncateToWidth(leftBody, leftWidth, "…");
|
|
418
421
|
// Pad every row to the full pane width so rows align regardless of whether they
|
|
419
422
|
// carry a right column (token usage / running… / queued).
|
|
@@ -640,11 +643,12 @@ function formatGroupHeader(meta, items, width, selected = false) {
|
|
|
640
643
|
* done/total count on the left, the per-turn token/elapsed/cost delta on the right.
|
|
641
644
|
* - Shows all tasks with all statuses (pending / in_progress / done / failed).
|
|
642
645
|
* The active row animates a braille spinner; pending rows read `queued`.
|
|
643
|
-
* - A single-cell owner glyph (
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* title names the owner: the subagent type
|
|
647
|
-
* ("[planner]", fed by `--team <url>`), or the
|
|
646
|
+
* - A single-cell owner glyph (◇ subagent / ▸ team role / ⧉ MCP) sits before
|
|
647
|
+
* the id where it disambiguates; main-owned rows carry no glyph in the flat
|
|
648
|
+
* lens (the rail already attributes the pane to the main agent). A text
|
|
649
|
+
* origin tag before the title names the owner: the subagent type
|
|
650
|
+
* ("[explore]"), the team role ("[planner]", fed by `--team <url>`), or the
|
|
651
|
+
* MCP server ("[github]").
|
|
648
652
|
* - Three views split by ownership (cycled via app.tasks.cycleView, shown as a
|
|
649
653
|
* `tasks · subagents · teams` switcher in the header):
|
|
650
654
|
* - flat ("tasks") — the main agent's own TodoWrite plan, with each
|