@kolisachint/hoocode-agent 0.4.73 → 0.4.74
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 +30 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +1 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +0 -1
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/routing/local-inference.d.ts +14 -15
- package/dist/core/routing/local-inference.d.ts.map +1 -1
- package/dist/core/routing/local-inference.js +6 -15
- package/dist/core/routing/local-inference.js.map +1 -1
- package/dist/core/subagent-pool-instance.d.ts.map +1 -1
- package/dist/core/subagent-pool-instance.js +27 -0
- package/dist/core/subagent-pool-instance.js.map +1 -1
- package/dist/core/subagent-pool.d.ts +26 -0
- package/dist/core/subagent-pool.d.ts.map +1 -1
- package/dist/core/subagent-pool.js +65 -18
- package/dist/core/subagent-pool.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +4 -0
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/task-store.d.ts +7 -0
- package/dist/core/task-store.d.ts.map +1 -1
- package/dist/core/task-store.js +3 -0
- package/dist/core/task-store.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/task-panel.js +4 -1
- package/dist/modes/interactive/components/task-panel.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/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task-store.js","sourceRoot":"","sources":["../../src/core/task-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Minimal in-process task store.\n *\n * Tracks short-lived tasks (e.g. subagent delegations) so the TUI task panel can\n * display active work. It is a process-level singleton because the tool that\n * creates tasks and the footer that renders them live in the same process and\n * there is no cross-process boundary to cross.\n */\n\nexport type TaskStatus = \"pending\" | \"in_progress\" | \"done\" | \"failed\";\n\n/**\n * What kind of background work owns a task, surfaced as a source glyph in the\n * pane. Unset means the main agent. A \"team\" origin is reserved for the\n * hooteams integration and stays unwired until that lands.\n */\nexport type TaskSource = \"subagent\" | \"mcp\";\n\n/**\n * Kind of agent that can own tasks in the pane's grouped views:\n * the main session (orchestrator), a spawned subagent, or a named\n * team role-agent (e.g. fed by a hooteams bridge).\n */\nexport type TaskAgentKind = \"main\" | \"subagent\" | \"role\";\n\n/** Lifecycle word shown as the agent's `[state]` tag in grouped views. */\nexport type TaskAgentState = \"active\" | \"running\" | \"done\" | \"queued\" | \"idle\" | \"waiting\" | \"failed\";\n\n/**\n * An agent that owns tasks, rendered as a group header in the pane's\n * `subagents` / `teams` views. Subagent dispatches register themselves here;\n * external orchestrators (hooteams) can upsert role-agents with handoffs.\n */\nexport interface TaskAgent {\n\treadonly id: string;\n\tname: string;\n\t/** Short descriptor after the name: \"orchestrator\", \"subagent\", or a team role like \"architect\". */\n\trole?: string;\n\tkind: TaskAgentKind;\n\tstate?: TaskAgentState;\n\t/** Handoff arrow text for team views (e.g. \"→ reviewer\", \"← builder\"). */\n\thandoff?: string;\n\t/** Per-agent token + cost totals, shown right-aligned on the group header. */\n\tstats?: { input: number; output: number; cost: number };\n}\n\nexport interface Task {\n\treadonly id: number;\n\ttitle: string;\n\tstatus: TaskStatus;\n\t/** Origin of the task (subagent delegation vs MCP tool call; unset = main agent); drives the pane's source glyph. */\n\tsource?: TaskSource;\n\t/**\n\t * Origin label shown as the row's `[tag]` in the task pane: the subagent\n\t * type for delegations (e.g. \"explore\"), the MCP server name for MCP tasks\n\t * (e.g. \"github\").\n\t */\n\tsubagentMode?: string;\n\t/** Id of the owning TaskAgent; drives grouping in the pane's subagents/teams views. */\n\tagent?: string;\n\t/**\n\t * Id of the task that spawned this one, linking a dispatched subagent (and its\n\t * own delegations) back to the Task call that created it. Root tasks omit it.\n\t * Drives the subagents lens's recursive task tree: a node's children are the\n\t * tasks whose `parentTaskId` is its id, so nesting deeper than one level (a\n\t * subagent that spawns a subagent) is visible. Set when a child subagent's task\n\t * subtree is merged into the parent (see finalizeDispatchResult).\n\t */\n\tparentTaskId?: number;\n\t/**\n\t * Short warning note surfaced as a ⚠ cue in the task pane (e.g. the subagent\n\t * fell back to the inherited model, or was skipped because the provider was\n\t * exhausted). Kept terse so it fits the row's right column.\n\t */\n\tnote?: string;\n\treadonly createdAt: number;\n\tupdatedAt: number;\n\t/** Token and cost usage attributed to this task (e.g. from a subagent session). */\n\tusage?: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\tcost: number;\n\t};\n}\n\nexport interface CreateTaskOptions {\n\tsource?: TaskSource;\n\tsubagentMode?: string;\n\tagent?: string;\n\tparentTaskId?: number;\n}\n\nexport type TaskPatch = Partial<\n\tPick<Task, \"title\" | \"status\" | \"source\" | \"subagentMode\" | \"agent\" | \"usage\" | \"note\" | \"parentTaskId\">\n>;\n\nexport type TaskAgentPatch = Partial<Omit<TaskAgent, \"id\">>;\n\n/**\n * Owner group for a task when no explicit agent is set: subagent-sourced work\n * falls into a generic \"subagent\" group, everything else belongs to main.\n * Shared by the store's reset() and the pane's grouped views so the two never\n * disagree about which agents still own live tasks.\n */\nexport function taskOwnerId(task: Pick<Task, \"agent\" | \"source\">): string {\n\tif (task.agent) return task.agent;\n\treturn task.source === \"subagent\" ? \"subagent\" : \"main\";\n}\n\ntype Listener = () => void;\n\nclass TaskStore {\n\tprivate tasks: Task[] = [];\n\tprivate taskAgents: TaskAgent[] = [];\n\tprivate nextId = 1;\n\tprivate readonly listeners = new Set<Listener>();\n\n\tcreate(title: string, options: CreateTaskOptions = {}): Task {\n\t\tconst now = Date.now();\n\t\tconst task: Task = {\n\t\t\tid: this.nextId++,\n\t\t\ttitle: title.trim() || \"(untitled task)\",\n\t\t\tstatus: \"pending\",\n\t\t\tsource: options.source,\n\t\t\tsubagentMode: options.subagentMode,\n\t\t\tagent: options.agent,\n\t\t\tparentTaskId: options.parentTaskId,\n\t\t\tcreatedAt: now,\n\t\t\tupdatedAt: now,\n\t\t};\n\t\tthis.tasks.push(task);\n\t\tthis.emit();\n\t\treturn task;\n\t}\n\n\tupdate(id: number, patch: TaskPatch): void {\n\t\tconst task = this.tasks.find((t) => t.id === id);\n\t\tif (!task) {\n\t\t\tconsole.warn(`[task-store] update: unknown task id ${id}`);\n\t\t\treturn;\n\t\t}\n\t\tif (patch.title !== undefined) task.title = patch.title;\n\t\tif (patch.status !== undefined) task.status = patch.status;\n\t\tif (patch.source !== undefined) task.source = patch.source;\n\t\tif (patch.subagentMode !== undefined) task.subagentMode = patch.subagentMode;\n\t\tif (patch.agent !== undefined) task.agent = patch.agent;\n\t\tif (patch.parentTaskId !== undefined) task.parentTaskId = patch.parentTaskId;\n\t\tif (patch.usage !== undefined) task.usage = patch.usage;\n\t\tif (patch.note !== undefined) task.note = patch.note;\n\t\ttask.updatedAt = Date.now();\n\t\tthis.emit();\n\t}\n\n\t/**\n\t * Register or update an agent for the grouped task views. Merges into an\n\t * existing entry with the same id (accumulated stats survive a re-dispatch);\n\t * creates it otherwise.\n\t */\n\tupsertAgent(agent: { id: string } & TaskAgentPatch & Pick<TaskAgent, \"name\" | \"kind\">): TaskAgent {\n\t\tconst existing = this.taskAgents.find((a) => a.id === agent.id);\n\t\tif (existing) {\n\t\t\tthis.applyAgentPatch(existing, agent);\n\t\t\tthis.emit();\n\t\t\treturn existing;\n\t\t}\n\t\tconst created: TaskAgent = {\n\t\t\tid: agent.id,\n\t\t\tname: agent.name,\n\t\t\trole: agent.role,\n\t\t\tkind: agent.kind,\n\t\t\tstate: agent.state,\n\t\t\thandoff: agent.handoff,\n\t\t\tstats: agent.stats,\n\t\t};\n\t\tthis.taskAgents.push(created);\n\t\tthis.emit();\n\t\treturn created;\n\t}\n\n\t/** Patch an existing agent (state/handoff/stats…). Unknown ids are ignored. */\n\tpatchAgent(id: string, patch: TaskAgentPatch): void {\n\t\tconst agent = this.taskAgents.find((a) => a.id === id);\n\t\tif (!agent) return;\n\t\tthis.applyAgentPatch(agent, patch);\n\t\tthis.emit();\n\t}\n\n\t/** Add a usage delta to an agent's running totals (creating them at zero). */\n\taddAgentStats(id: string, delta: { input?: number; output?: number; cost?: number }): void {\n\t\tconst agent = this.taskAgents.find((a) => a.id === id);\n\t\tif (!agent) {\n\t\t\tconsole.warn(`[task-store] addAgentStats: unknown agent id \"${id}\"`);\n\t\t\treturn;\n\t\t}\n\t\tconst stats = agent.stats ?? { input: 0, output: 0, cost: 0 };\n\t\tstats.input += delta.input ?? 0;\n\t\tstats.output += delta.output ?? 0;\n\t\tstats.cost += delta.cost ?? 0;\n\t\tagent.stats = stats;\n\t\tthis.emit();\n\t}\n\n\tagents(): readonly TaskAgent[] {\n\t\treturn this.taskAgents;\n\t}\n\n\tprivate applyAgentPatch(agent: TaskAgent, patch: TaskAgentPatch): void {\n\t\tif (patch.name !== undefined) agent.name = patch.name;\n\t\tif (patch.role !== undefined) agent.role = patch.role;\n\t\tif (patch.kind !== undefined) agent.kind = patch.kind;\n\t\tif (patch.state !== undefined) agent.state = patch.state;\n\t\tif (patch.handoff !== undefined) agent.handoff = patch.handoff;\n\t\tif (patch.stats !== undefined) agent.stats = patch.stats;\n\t}\n\n\tremove(id: number): void {\n\t\tconst idx = this.tasks.findIndex((t) => t.id === id);\n\t\tif (idx === -1) return;\n\t\tthis.tasks.splice(idx, 1);\n\t\tthis.emit();\n\t}\n\n\t/**\n\t * Drop finished tasks and restart numbering from #1 once the pane is empty.\n\t *\n\t * Called when a new user message arrives: finished tasks from the previous turn\n\t * stay visible (with their final status) for the whole turn and are wiped only\n\t * when the user starts the next turn, so the next turn opens with an empty pane\n\t * and its first task is #1 again. Active (pending/in_progress) tasks are kept —\n\t * a follow-up/steer message can arrive while a subagent is still running, and\n\t * dropping its task here would orphan the live work (its later status update\n\t * would target a removed id and silently vanish). Numbering only restarts once\n\t * no active task survives, so ids never collide with a kept task.\n\t * Agents with accumulated stats are preserved across resets so cross-turn cost\n\t * accounting survives.\n\t */\n\treset(): void {\n\t\tconst active = this.tasks.filter((t) => t.status === \"pending\" || t.status === \"in_progress\");\n\t\tif (active.length === this.tasks.length && this.nextId === 1) return;\n\t\tthis.tasks = active;\n\t\tif (active.length === 0) {\n\t\t\tthis.nextId = 1;\n\t\t\t// Keep agents with non-zero accumulated stats so cross-turn cost accounting\n\t\t\t// survives; drop everyone else (fresh turn opens with a clean roster).\n\t\t\tthis.taskAgents = this.taskAgents.filter(\n\t\t\t\t(a) => a.stats && (a.stats.input > 0 || a.stats.output > 0 || a.stats.cost > 0),\n\t\t\t);\n\t\t} else {\n\t\t\tconst liveOwners = new Set(active.map(taskOwnerId));\n\t\t\tthis.taskAgents = this.taskAgents.filter(\n\t\t\t\t(a) => liveOwners.has(a.id) || (a.stats && (a.stats.input > 0 || a.stats.output > 0 || a.stats.cost > 0)),\n\t\t\t);\n\t\t}\n\t\tthis.emit();\n\t}\n\n\tlist(): readonly Task[] {\n\t\treturn this.tasks;\n\t}\n\n\t/** Wipe all tasks and restart numbering. Intended for test isolation only. */\n\tclear(): void {\n\t\tthis.tasks = [];\n\t\tthis.taskAgents = [];\n\t\tthis.nextId = 1;\n\t\tthis.emit();\n\t}\n\n\tsubscribe(listener: Listener): () => void {\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t}\n\n\tprivate emit(): void {\n\t\tfor (const listener of this.listeners) {\n\t\t\tlistener();\n\t\t}\n\t}\n}\n\n/** Shared, process-wide task store. */\nexport const taskStore = new TaskStore();\n"]}
|
|
1
|
+
{"version":3,"file":"task-store.js","sourceRoot":"","sources":["../../src/core/task-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Minimal in-process task store.\n *\n * Tracks short-lived tasks (e.g. subagent delegations) so the TUI task panel can\n * display active work. It is a process-level singleton because the tool that\n * creates tasks and the footer that renders them live in the same process and\n * there is no cross-process boundary to cross.\n */\n\nexport type TaskStatus = \"pending\" | \"in_progress\" | \"done\" | \"failed\";\n\n/**\n * What kind of background work owns a task, surfaced as a source glyph in the\n * pane. Unset means the main agent. A \"team\" origin is reserved for the\n * hooteams integration and stays unwired until that lands.\n */\nexport type TaskSource = \"subagent\" | \"mcp\";\n\n/**\n * Kind of agent that can own tasks in the pane's grouped views:\n * the main session (orchestrator), a spawned subagent, or a named\n * team role-agent (e.g. fed by a hooteams bridge).\n */\nexport type TaskAgentKind = \"main\" | \"subagent\" | \"role\";\n\n/** Lifecycle word shown as the agent's `[state]` tag in grouped views. */\nexport type TaskAgentState = \"active\" | \"running\" | \"done\" | \"queued\" | \"idle\" | \"waiting\" | \"failed\";\n\n/**\n * An agent that owns tasks, rendered as a group header in the pane's\n * `subagents` / `teams` views. Subagent dispatches register themselves here;\n * external orchestrators (hooteams) can upsert role-agents with handoffs.\n */\nexport interface TaskAgent {\n\treadonly id: string;\n\tname: string;\n\t/** Short descriptor after the name: \"orchestrator\", \"subagent\", or a team role like \"architect\". */\n\trole?: string;\n\tkind: TaskAgentKind;\n\tstate?: TaskAgentState;\n\t/** Handoff arrow text for team views (e.g. \"→ reviewer\", \"← builder\"). */\n\thandoff?: string;\n\t/**\n\t * Live activity descriptor for a running subagent (e.g. the tool it is\n\t * currently executing), fed by the pool's `task_progress` events. Empty string\n\t * means \"no current activity\" — `applyAgentPatch` skips `undefined`, so callers\n\t * clear it by patching `\"\"`, not `undefined`.\n\t */\n\tactivity?: string;\n\t/** Per-agent token + cost totals, shown right-aligned on the group header. */\n\tstats?: { input: number; output: number; cost: number };\n}\n\nexport interface Task {\n\treadonly id: number;\n\ttitle: string;\n\tstatus: TaskStatus;\n\t/** Origin of the task (subagent delegation vs MCP tool call; unset = main agent); drives the pane's source glyph. */\n\tsource?: TaskSource;\n\t/**\n\t * Origin label shown as the row's `[tag]` in the task pane: the subagent\n\t * type for delegations (e.g. \"explore\"), the MCP server name for MCP tasks\n\t * (e.g. \"github\").\n\t */\n\tsubagentMode?: string;\n\t/** Id of the owning TaskAgent; drives grouping in the pane's subagents/teams views. */\n\tagent?: string;\n\t/**\n\t * Id of the task that spawned this one, linking a dispatched subagent (and its\n\t * own delegations) back to the Task call that created it. Root tasks omit it.\n\t * Drives the subagents lens's recursive task tree: a node's children are the\n\t * tasks whose `parentTaskId` is its id, so nesting deeper than one level (a\n\t * subagent that spawns a subagent) is visible. Set when a child subagent's task\n\t * subtree is merged into the parent (see finalizeDispatchResult).\n\t */\n\tparentTaskId?: number;\n\t/**\n\t * Short warning note surfaced as a ⚠ cue in the task pane (e.g. the subagent\n\t * fell back to the inherited model, or was skipped because the provider was\n\t * exhausted). Kept terse so it fits the row's right column.\n\t */\n\tnote?: string;\n\treadonly createdAt: number;\n\tupdatedAt: number;\n\t/** Token and cost usage attributed to this task (e.g. from a subagent session). */\n\tusage?: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\tcost: number;\n\t};\n}\n\nexport interface CreateTaskOptions {\n\tsource?: TaskSource;\n\tsubagentMode?: string;\n\tagent?: string;\n\tparentTaskId?: number;\n}\n\nexport type TaskPatch = Partial<\n\tPick<Task, \"title\" | \"status\" | \"source\" | \"subagentMode\" | \"agent\" | \"usage\" | \"note\" | \"parentTaskId\">\n>;\n\nexport type TaskAgentPatch = Partial<Omit<TaskAgent, \"id\">>;\n\n/**\n * Owner group for a task when no explicit agent is set: subagent-sourced work\n * falls into a generic \"subagent\" group, everything else belongs to main.\n * Shared by the store's reset() and the pane's grouped views so the two never\n * disagree about which agents still own live tasks.\n */\nexport function taskOwnerId(task: Pick<Task, \"agent\" | \"source\">): string {\n\tif (task.agent) return task.agent;\n\treturn task.source === \"subagent\" ? \"subagent\" : \"main\";\n}\n\ntype Listener = () => void;\n\nclass TaskStore {\n\tprivate tasks: Task[] = [];\n\tprivate taskAgents: TaskAgent[] = [];\n\tprivate nextId = 1;\n\tprivate readonly listeners = new Set<Listener>();\n\n\tcreate(title: string, options: CreateTaskOptions = {}): Task {\n\t\tconst now = Date.now();\n\t\tconst task: Task = {\n\t\t\tid: this.nextId++,\n\t\t\ttitle: title.trim() || \"(untitled task)\",\n\t\t\tstatus: \"pending\",\n\t\t\tsource: options.source,\n\t\t\tsubagentMode: options.subagentMode,\n\t\t\tagent: options.agent,\n\t\t\tparentTaskId: options.parentTaskId,\n\t\t\tcreatedAt: now,\n\t\t\tupdatedAt: now,\n\t\t};\n\t\tthis.tasks.push(task);\n\t\tthis.emit();\n\t\treturn task;\n\t}\n\n\tupdate(id: number, patch: TaskPatch): void {\n\t\tconst task = this.tasks.find((t) => t.id === id);\n\t\tif (!task) {\n\t\t\tconsole.warn(`[task-store] update: unknown task id ${id}`);\n\t\t\treturn;\n\t\t}\n\t\tif (patch.title !== undefined) task.title = patch.title;\n\t\tif (patch.status !== undefined) task.status = patch.status;\n\t\tif (patch.source !== undefined) task.source = patch.source;\n\t\tif (patch.subagentMode !== undefined) task.subagentMode = patch.subagentMode;\n\t\tif (patch.agent !== undefined) task.agent = patch.agent;\n\t\tif (patch.parentTaskId !== undefined) task.parentTaskId = patch.parentTaskId;\n\t\tif (patch.usage !== undefined) task.usage = patch.usage;\n\t\tif (patch.note !== undefined) task.note = patch.note;\n\t\ttask.updatedAt = Date.now();\n\t\tthis.emit();\n\t}\n\n\t/**\n\t * Register or update an agent for the grouped task views. Merges into an\n\t * existing entry with the same id (accumulated stats survive a re-dispatch);\n\t * creates it otherwise.\n\t */\n\tupsertAgent(agent: { id: string } & TaskAgentPatch & Pick<TaskAgent, \"name\" | \"kind\">): TaskAgent {\n\t\tconst existing = this.taskAgents.find((a) => a.id === agent.id);\n\t\tif (existing) {\n\t\t\tthis.applyAgentPatch(existing, agent);\n\t\t\tthis.emit();\n\t\t\treturn existing;\n\t\t}\n\t\tconst created: TaskAgent = {\n\t\t\tid: agent.id,\n\t\t\tname: agent.name,\n\t\t\trole: agent.role,\n\t\t\tkind: agent.kind,\n\t\t\tstate: agent.state,\n\t\t\thandoff: agent.handoff,\n\t\t\tactivity: agent.activity,\n\t\t\tstats: agent.stats,\n\t\t};\n\t\tthis.taskAgents.push(created);\n\t\tthis.emit();\n\t\treturn created;\n\t}\n\n\t/** Patch an existing agent (state/handoff/stats…). Unknown ids are ignored. */\n\tpatchAgent(id: string, patch: TaskAgentPatch): void {\n\t\tconst agent = this.taskAgents.find((a) => a.id === id);\n\t\tif (!agent) return;\n\t\tthis.applyAgentPatch(agent, patch);\n\t\tthis.emit();\n\t}\n\n\t/** Add a usage delta to an agent's running totals (creating them at zero). */\n\taddAgentStats(id: string, delta: { input?: number; output?: number; cost?: number }): void {\n\t\tconst agent = this.taskAgents.find((a) => a.id === id);\n\t\tif (!agent) {\n\t\t\tconsole.warn(`[task-store] addAgentStats: unknown agent id \"${id}\"`);\n\t\t\treturn;\n\t\t}\n\t\tconst stats = agent.stats ?? { input: 0, output: 0, cost: 0 };\n\t\tstats.input += delta.input ?? 0;\n\t\tstats.output += delta.output ?? 0;\n\t\tstats.cost += delta.cost ?? 0;\n\t\tagent.stats = stats;\n\t\tthis.emit();\n\t}\n\n\tagents(): readonly TaskAgent[] {\n\t\treturn this.taskAgents;\n\t}\n\n\tprivate applyAgentPatch(agent: TaskAgent, patch: TaskAgentPatch): void {\n\t\tif (patch.name !== undefined) agent.name = patch.name;\n\t\tif (patch.role !== undefined) agent.role = patch.role;\n\t\tif (patch.kind !== undefined) agent.kind = patch.kind;\n\t\tif (patch.state !== undefined) agent.state = patch.state;\n\t\tif (patch.activity !== undefined) agent.activity = patch.activity;\n\t\tif (patch.handoff !== undefined) agent.handoff = patch.handoff;\n\t\tif (patch.stats !== undefined) agent.stats = patch.stats;\n\t}\n\n\tremove(id: number): void {\n\t\tconst idx = this.tasks.findIndex((t) => t.id === id);\n\t\tif (idx === -1) return;\n\t\tthis.tasks.splice(idx, 1);\n\t\tthis.emit();\n\t}\n\n\t/**\n\t * Drop finished tasks and restart numbering from #1 once the pane is empty.\n\t *\n\t * Called when a new user message arrives: finished tasks from the previous turn\n\t * stay visible (with their final status) for the whole turn and are wiped only\n\t * when the user starts the next turn, so the next turn opens with an empty pane\n\t * and its first task is #1 again. Active (pending/in_progress) tasks are kept —\n\t * a follow-up/steer message can arrive while a subagent is still running, and\n\t * dropping its task here would orphan the live work (its later status update\n\t * would target a removed id and silently vanish). Numbering only restarts once\n\t * no active task survives, so ids never collide with a kept task.\n\t * Agents with accumulated stats are preserved across resets so cross-turn cost\n\t * accounting survives.\n\t */\n\treset(): void {\n\t\tconst active = this.tasks.filter((t) => t.status === \"pending\" || t.status === \"in_progress\");\n\t\tif (active.length === this.tasks.length && this.nextId === 1) return;\n\t\tthis.tasks = active;\n\t\tif (active.length === 0) {\n\t\t\tthis.nextId = 1;\n\t\t\t// Keep agents with non-zero accumulated stats so cross-turn cost accounting\n\t\t\t// survives; drop everyone else (fresh turn opens with a clean roster).\n\t\t\tthis.taskAgents = this.taskAgents.filter(\n\t\t\t\t(a) => a.stats && (a.stats.input > 0 || a.stats.output > 0 || a.stats.cost > 0),\n\t\t\t);\n\t\t} else {\n\t\t\tconst liveOwners = new Set(active.map(taskOwnerId));\n\t\t\tthis.taskAgents = this.taskAgents.filter(\n\t\t\t\t(a) => liveOwners.has(a.id) || (a.stats && (a.stats.input > 0 || a.stats.output > 0 || a.stats.cost > 0)),\n\t\t\t);\n\t\t}\n\t\tthis.emit();\n\t}\n\n\tlist(): readonly Task[] {\n\t\treturn this.tasks;\n\t}\n\n\t/** Wipe all tasks and restart numbering. Intended for test isolation only. */\n\tclear(): void {\n\t\tthis.tasks = [];\n\t\tthis.taskAgents = [];\n\t\tthis.nextId = 1;\n\t\tthis.emit();\n\t}\n\n\tsubscribe(listener: Listener): () => void {\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t}\n\n\tprivate emit(): void {\n\t\tfor (const listener of this.listeners) {\n\t\t\tlistener();\n\t\t}\n\t}\n}\n\n/** Shared, process-wide task store. */\nexport const taskStore = new TaskStore();\n"]}
|
|
@@ -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;AAiC1E;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;AA+e3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;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;IAKzB,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,CAEjB;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,CA2I9B;CACD","sourcesContent":["import type { Component, Focusable, TUI } from \"@kolisachint/hoocode-tui\";\nimport { getKeybindings, matchesKey, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\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 { theme } from \"../theme/theme.js\";\n\nconst TASK_STATUS_ICON: Record<TaskStatus, string> = {\n\tpending: \"●\",\n\tin_progress: \"◐\",\n\tdone: \"✓\",\n\tfailed: \"✗\",\n};\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 row animates in step. */\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nconst SPINNER_INTERVAL_MS = 80;\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};\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\treturn \"dim\";\n\t}\n}\n\n/** Format a duration in seconds into a compact, terminal-friendly string. */\nfunction formatDuration(secs: number): string {\n\tconst s = Math.max(0, secs);\n\tif (s < 10) return `${s.toFixed(1)}s`;\n\tif (s < 60) return `${Math.round(s)}s`;\n\tconst mins = Math.floor(s / 60);\n\tconst rem = Math.round(s % 60);\n\treturn `${mins}m${rem.toString().padStart(2, \"0\")}s`;\n}\n\n/** Wall-clock time a task occupied, derived from its create/update stamps. */\nfunction taskElapsedSecs(task: Task): number {\n\treturn Math.max(0, (task.updatedAt - task.createdAt) / 1000);\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 = formatDuration(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\tidColWidth: 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\tconst styledSource = grouped ? \"\" : theme.fg(\"dim\", sourceGlyph);\n\n\t// Right-pad the id to the shared column width so titles line up across rows even\n\t// when ids differ in digit count (#1 vs #10). Padding is plain spaces inside the\n\t// dim styling, so it adds no visible color.\n\tconst idLabel = `#${task.id}`.padEnd(idColWidth);\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\tif (isMcp) tag = `[${task.subagentMode ?? \"MCP\"}]`;\n\telse if (!grouped) {\n\t\tif (task.subagentMode) tag = `[${task.subagentMode}]`;\n\t\telse if (ownerKind === \"role\" && options.owner) tag = `[${options.owner.name}]`;\n\t}\n\tconst styledTag = tag ? `${theme.fg(\"accent\", tag)} ` : \"\";\n\tconst title = task.title;\n\t// The id recedes (dim); 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\tconst styledId = theme.fg(\"dim\", idLabel);\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 \"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 audit stamp (tokens + elapsed); 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\tconst parts: string[] = [];\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\tconst elapsed = formatDuration(taskElapsedSecs(task));\n\t\tif (tokenText) {\n\t\t\tparts.push(tokenText, elapsed);\n\t\t\trightStyled = theme.fg(\"muted\", tokenText) + theme.fg(\"dim\", ` · ${elapsed}`);\n\t\t} else {\n\t\t\tparts.push(elapsed);\n\t\t\trightStyled = theme.fg(\"dim\", elapsed);\n\t\t}\n\t\trightPlain = parts.join(\" · \");\n\t} else if (task.status === \"in_progress\") {\n\t\trightPlain = \"running…\";\n\t\trightStyled = theme.fg(\"warning\", rightPlain);\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 = `⚠ ${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} ${styledId} ${styledTag}${styledTitle}`\n\t\t: `${treePrefix}${icon} ${styledSource} ${styledId} ${styledTag}${styledTitle}`;\n\tconst left = truncateToWidth(leftBody, leftWidth, \"…\");\n\n\tif (!rightPlain) return left;\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/**\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\treturn tasks.filter(isMainTask);\n\t\tcase \"subagents\":\n\t\t\treturn tasks.filter((t) => t.source === \"subagent\" || t.source === \"mcp\" || t.parentTaskId !== undefined);\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\tif (groups.has(\"main\")) order.push(\"main\");\n\tfor (const agent of agents) {\n\t\tif (groups.has(agent.id) && !order.includes(agent.id)) order.push(agent.id);\n\t}\n\tfor (const id of groups.keys()) {\n\t\tif (!order.includes(id)) order.push(id);\n\t}\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\tconst glyph = selected\n\t\t? theme.fg(\"accent\", \"▶\")\n\t\t: theme.fg(AGENT_GLYPH_COLOR[meta.kind], AGENT_GLYPH[meta.kind] ?? AGENT_GLYPH.subagent);\n\tconst name = selected ? theme.bold(theme.fg(\"accent\", meta.name)) : theme.bold(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\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}${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\") — only the main agent's own TodoWrite plan;\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// 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// No cached rendering state.\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 (matchesKey(data, \"n\")) {\n\t\t\tconst role = this.focusedRole();\n\t\t\tif (role) this.onNudge?.(role);\n\t\t} else if (matchesKey(data, \"a\")) {\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\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\tconst lensTasks = 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 = lensTasks.reduce((sum, t) => sum + taskElapsedSecs(t), 0);\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\t// Width of the id column, sized to the widest id on screen, so every title\n\t\t// starts at the same column regardless of digit count (#1 vs #10 vs #100).\n\t\tconst idColWidth = lensTasks.reduce((max, t) => Math.max(max, `#${t.id}`.length), 0);\n\n\t\tconst lines: string[] = [gutter + formatHeader(lensTasks, inner, state, totalSecs, view, available)];\n\n\t\tif (view === \"flat\") {\n\t\t\t// Only the main agent's own TodoWrite plan — delegated (subagent/MCP) work\n\t\t\t// belongs to the subagents lens. Resolve each row's owner from the roster\n\t\t\t// so the glyph/tag reflect the owning agent's kind, not just the source.\n\t\t\tconst agentById = new Map(allAgents.map((a) => [a.id, a]));\n\t\t\tfor (const task of tasks) {\n\t\t\t\tif (!isMainTask(task)) continue;\n\t\t\t\tlines.push(\n\t\t\t\t\tgutter +\n\t\t\t\t\t\tformatTaskLine(task, inner, this.frame, idColWidth, { owner: agentById.get(taskOwnerId(task)) }),\n\t\t\t\t);\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. With only top-level tasks the roots\n\t\t\t// carry an empty prefix and read exactly like flat rows (no extra indent).\n\t\t\tconst childrenByParent = new Map<number, Task[]>();\n\t\t\tfor (const task of tasks) {\n\t\t\t\tif (task.parentTaskId === undefined) continue;\n\t\t\t\tconst siblings = childrenByParent.get(task.parentTaskId);\n\t\t\t\tif (siblings) siblings.push(task);\n\t\t\t\telse childrenByParent.set(task.parentTaskId, [task]);\n\t\t\t}\n\t\t\tconst roots = tasks.filter(\n\t\t\t\t(t) => t.parentTaskId === undefined && (t.source === \"subagent\" || t.source === \"mcp\"),\n\t\t\t);\n\t\t\tconst walk = (task: Task, prefix: string, isLast: boolean, isRoot: boolean): void => {\n\t\t\t\tconst connector = isRoot ? \"\" : `${prefix}${isLast ? \"└─ \" : \"├─ \"}`;\n\t\t\t\tlines.push(gutter + formatTaskLine(task, inner, this.frame, idColWidth, { treePrefix: connector }));\n\t\t\t\tconst kids = childrenByParent.get(task.id) ?? [];\n\t\t\t\tconst childPrefix = isRoot ? \"\" : `${prefix}${isLast ? \" \" : \"│ \"}`;\n\t\t\t\tfor (let i = 0; i < kids.length; i++) {\n\t\t\t\t\twalk(kids[i] as Task, childPrefix, i === kids.length - 1, false);\n\t\t\t\t}\n\t\t\t};\n\t\t\tfor (const root of roots) walk(root, \"\", true, true);\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 + formatTaskLine(task, inner, this.frame, idColWidth, { 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\tlines.push(\n\t\t\t\tgutter + truncateToWidth(theme.fg(\"dim\", \"↑/↓ select · n nudge · a attach · q/esc back\"), inner, \"…\"),\n\t\t\t);\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;AAiC1E;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;AAkf3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;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;IAKzB,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,CAEjB;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,CA2I9B;CACD","sourcesContent":["import type { Component, Focusable, TUI } from \"@kolisachint/hoocode-tui\";\nimport { getKeybindings, matchesKey, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\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 { theme } from \"../theme/theme.js\";\n\nconst TASK_STATUS_ICON: Record<TaskStatus, string> = {\n\tpending: \"●\",\n\tin_progress: \"◐\",\n\tdone: \"✓\",\n\tfailed: \"✗\",\n};\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 row animates in step. */\nconst SPINNER_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nconst SPINNER_INTERVAL_MS = 80;\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};\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\treturn \"dim\";\n\t}\n}\n\n/** Format a duration in seconds into a compact, terminal-friendly string. */\nfunction formatDuration(secs: number): string {\n\tconst s = Math.max(0, secs);\n\tif (s < 10) return `${s.toFixed(1)}s`;\n\tif (s < 60) return `${Math.round(s)}s`;\n\tconst mins = Math.floor(s / 60);\n\tconst rem = Math.round(s % 60);\n\treturn `${mins}m${rem.toString().padStart(2, \"0\")}s`;\n}\n\n/** Wall-clock time a task occupied, derived from its create/update stamps. */\nfunction taskElapsedSecs(task: Task): number {\n\treturn Math.max(0, (task.updatedAt - task.createdAt) / 1000);\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 = formatDuration(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\tidColWidth: 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\tconst styledSource = grouped ? \"\" : theme.fg(\"dim\", sourceGlyph);\n\n\t// Right-pad the id to the shared column width so titles line up across rows even\n\t// when ids differ in digit count (#1 vs #10). Padding is plain spaces inside the\n\t// dim styling, so it adds no visible color.\n\tconst idLabel = `#${task.id}`.padEnd(idColWidth);\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\tif (isMcp) tag = `[${task.subagentMode ?? \"MCP\"}]`;\n\telse if (!grouped) {\n\t\tif (task.subagentMode) tag = `[${task.subagentMode}]`;\n\t\telse if (ownerKind === \"role\" && options.owner) tag = `[${options.owner.name}]`;\n\t}\n\tconst styledTag = tag ? `${theme.fg(\"accent\", tag)} ` : \"\";\n\tconst title = task.title;\n\t// The id recedes (dim); 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\tconst styledId = theme.fg(\"dim\", idLabel);\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 \"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 audit stamp (tokens + elapsed); 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\tconst parts: string[] = [];\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\tconst elapsed = formatDuration(taskElapsedSecs(task));\n\t\tif (tokenText) {\n\t\t\tparts.push(tokenText, elapsed);\n\t\t\trightStyled = theme.fg(\"muted\", tokenText) + theme.fg(\"dim\", ` · ${elapsed}`);\n\t\t} else {\n\t\t\tparts.push(elapsed);\n\t\t\trightStyled = theme.fg(\"dim\", elapsed);\n\t\t}\n\t\trightPlain = parts.join(\" · \");\n\t} else if (task.status === \"in_progress\") {\n\t\trightPlain = \"running…\";\n\t\trightStyled = theme.fg(\"warning\", rightPlain);\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 = `⚠ ${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} ${styledId} ${styledTag}${styledTitle}`\n\t\t: `${treePrefix}${icon} ${styledSource} ${styledId} ${styledTag}${styledTitle}`;\n\tconst left = truncateToWidth(leftBody, leftWidth, \"…\");\n\n\tif (!rightPlain) return left;\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/**\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\treturn tasks.filter(isMainTask);\n\t\tcase \"subagents\":\n\t\t\treturn tasks.filter((t) => t.source === \"subagent\" || t.source === \"mcp\" || t.parentTaskId !== undefined);\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\tif (groups.has(\"main\")) order.push(\"main\");\n\tfor (const agent of agents) {\n\t\tif (groups.has(agent.id) && !order.includes(agent.id)) order.push(agent.id);\n\t}\n\tfor (const id of groups.keys()) {\n\t\tif (!order.includes(id)) order.push(id);\n\t}\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\tconst glyph = selected\n\t\t? theme.fg(\"accent\", \"▶\")\n\t\t: theme.fg(AGENT_GLYPH_COLOR[meta.kind], AGENT_GLYPH[meta.kind] ?? AGENT_GLYPH.subagent);\n\tconst name = selected ? theme.bold(theme.fg(\"accent\", meta.name)) : theme.bold(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\") — only the main agent's own TodoWrite plan;\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// 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// No cached rendering state.\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 (matchesKey(data, \"n\")) {\n\t\t\tconst role = this.focusedRole();\n\t\t\tif (role) this.onNudge?.(role);\n\t\t} else if (matchesKey(data, \"a\")) {\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\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\tconst lensTasks = 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 = lensTasks.reduce((sum, t) => sum + taskElapsedSecs(t), 0);\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\t// Width of the id column, sized to the widest id on screen, so every title\n\t\t// starts at the same column regardless of digit count (#1 vs #10 vs #100).\n\t\tconst idColWidth = lensTasks.reduce((max, t) => Math.max(max, `#${t.id}`.length), 0);\n\n\t\tconst lines: string[] = [gutter + formatHeader(lensTasks, inner, state, totalSecs, view, available)];\n\n\t\tif (view === \"flat\") {\n\t\t\t// Only the main agent's own TodoWrite plan — delegated (subagent/MCP) work\n\t\t\t// belongs to the subagents lens. Resolve each row's owner from the roster\n\t\t\t// so the glyph/tag reflect the owning agent's kind, not just the source.\n\t\t\tconst agentById = new Map(allAgents.map((a) => [a.id, a]));\n\t\t\tfor (const task of tasks) {\n\t\t\t\tif (!isMainTask(task)) continue;\n\t\t\t\tlines.push(\n\t\t\t\t\tgutter +\n\t\t\t\t\t\tformatTaskLine(task, inner, this.frame, idColWidth, { owner: agentById.get(taskOwnerId(task)) }),\n\t\t\t\t);\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. With only top-level tasks the roots\n\t\t\t// carry an empty prefix and read exactly like flat rows (no extra indent).\n\t\t\tconst childrenByParent = new Map<number, Task[]>();\n\t\t\tfor (const task of tasks) {\n\t\t\t\tif (task.parentTaskId === undefined) continue;\n\t\t\t\tconst siblings = childrenByParent.get(task.parentTaskId);\n\t\t\t\tif (siblings) siblings.push(task);\n\t\t\t\telse childrenByParent.set(task.parentTaskId, [task]);\n\t\t\t}\n\t\t\tconst roots = tasks.filter(\n\t\t\t\t(t) => t.parentTaskId === undefined && (t.source === \"subagent\" || t.source === \"mcp\"),\n\t\t\t);\n\t\t\tconst walk = (task: Task, prefix: string, isLast: boolean, isRoot: boolean): void => {\n\t\t\t\tconst connector = isRoot ? \"\" : `${prefix}${isLast ? \"└─ \" : \"├─ \"}`;\n\t\t\t\tlines.push(gutter + formatTaskLine(task, inner, this.frame, idColWidth, { treePrefix: connector }));\n\t\t\t\tconst kids = childrenByParent.get(task.id) ?? [];\n\t\t\t\tconst childPrefix = isRoot ? \"\" : `${prefix}${isLast ? \" \" : \"│ \"}`;\n\t\t\t\tfor (let i = 0; i < kids.length; i++) {\n\t\t\t\t\twalk(kids[i] as Task, childPrefix, i === kids.length - 1, false);\n\t\t\t\t}\n\t\t\t};\n\t\t\tfor (const root of roots) walk(root, \"\", true, true);\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 + formatTaskLine(task, inner, this.frame, idColWidth, { 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\tlines.push(\n\t\t\t\tgutter + truncateToWidth(theme.fg(\"dim\", \"↑/↓ select · n nudge · a attach · q/esc back\"), inner, \"…\"),\n\t\t\t);\n\t\t}\n\t\treturn lines;\n\t}\n}\n"]}
|
|
@@ -449,6 +449,9 @@ function formatGroupHeader(meta, items, width, selected = false) {
|
|
|
449
449
|
: theme.fg("dim", ` · ${meta.role}`)
|
|
450
450
|
: "";
|
|
451
451
|
const state = meta.state ? ` ${theme.fg(AGENT_STATE_COLOR[meta.state] ?? "dim", `[${meta.state}]`)}` : "";
|
|
452
|
+
// Live activity for a running subagent (e.g. the tool it's executing). Empty
|
|
453
|
+
// string means idle/between-tools, so it disappears rather than lingering stale.
|
|
454
|
+
const activity = meta.activity ? theme.fg("dim", ` ⋯ ${meta.activity}`) : "";
|
|
452
455
|
const handoff = meta.handoff ? ` ${theme.fg("dim", meta.handoff)}` : "";
|
|
453
456
|
const done = items.filter((t) => t.status === "done").length;
|
|
454
457
|
const countPlain = `${done}/${items.length}`;
|
|
@@ -463,7 +466,7 @@ function formatGroupHeader(meta, items, width, selected = false) {
|
|
|
463
466
|
}
|
|
464
467
|
const rightWidth = visibleWidth(rightPlain) + 1;
|
|
465
468
|
const leftWidth = Math.max(0, width - rightWidth);
|
|
466
|
-
const left = truncateToWidth(`${glyph} ${name}${role}${state}${handoff}`, leftWidth, "…");
|
|
469
|
+
const left = truncateToWidth(`${glyph} ${name}${role}${state}${activity}${handoff}`, leftWidth, "…");
|
|
467
470
|
const pad = Math.max(1, width - visibleWidth(left) - visibleWidth(rightPlain));
|
|
468
471
|
return left + " ".repeat(pad) + rightStyled;
|
|
469
472
|
}
|