/** * task-browser.tsx — Ink TaskBrowserView component. * * Replaces the neo-blessed TaskBrowser class with a declarative React * component. The parent manages data loading, task graph building, * session persistence, and selected state; this component handles * rendering and keybind dispatch. * * Layout: * ┌───────────────────────────────────────────────────────┐ * │ WOMBO-COMBO Task Browser │ 42 tasks │ 5 selected │ * ├──────────────────────────────┬────────────────────────┤ * │ ☑ json-features-check done │ Title: ... │ * │ ☑ json-features-arch done │ Status: backlog │ * │ ☐ json-ops-commands back │ Priority: high │ * │ ── stream 2 ── │ Effort: PT2H │ * │ ☐ tdd-test-detection done │ Depends on: │ * ├──────────────────────────────┴────────────────────────┤ * │ Space:toggle S:stream L:launch O:sort Q:quit │ * └───────────────────────────────────────────────────────┘ */ import React from "react"; import { Box, Text, useInput } from "ink"; import type { Task } from "../lib/tasks"; import type { UsageTotals } from "../lib/token-usage"; import type { SortField } from "../lib/tui-session"; import { formatTokenCount, formatCost } from "./usage-overlay"; import { TASK_STATUS_COLORS, TASK_STATUS_ABBREV, TASK_PRIORITY_COLORS, } from "./tui-constants"; import { useTerminalSize } from "./use-terminal-size"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * A task node in the display tree with computed metadata. * The parent computes these from the dependency graph. */ export interface TaskNode { task: Task; /** Depth in the dependency chain (0 = leaf/no deps) */ depth: number; /** ID of the stream (connected component) */ streamId: string; /** Task IDs that depend on this task */ dependedOnBy: string[]; /** Whether all dependencies of this task are done */ depsReady: boolean; } /** Priority abbreviations for display */ const PRIORITY_ABBREV: Record = { critical: "CRIT", high: "HIGH", medium: "MED", low: "LOW", wishlist: "WISH", }; export interface TaskBrowserViewProps { /** Flat ordered list of task nodes to display. */ nodes: TaskNode[]; /** Currently selected index. */ selectedIndex: number; /** Set of selected task IDs (for checkboxes). */ selectedIds: Set; /** Current sort field. */ sortBy: SortField; /** Max concurrent agents. */ maxConcurrent: number; /** Whether done tasks are hidden. */ hideDone: boolean; /** Total task count (may differ from nodes.length if hideDone). */ totalTaskCount: number; /** Count of done tasks. */ doneCount: number; /** Count of planned (queued for daemon) tasks. */ readyCount: number; /** Per-task token usage data. */ taskUsage?: Map; /** Quest title (if in quest-filtered mode). */ questTitle?: string; /** Whether a wave is currently running. */ hasRunningWave?: boolean; // Callbacks onSelectionChange: (index: number) => void; onToggle: () => void; onToggleStream: () => void; onToggleAll: () => void; onCycleSort: () => void; onChangePriority: (delta: number) => void; onToggleDone: () => void; onCycleConcurrency: () => void; onQuit: () => void; onBack?: () => void; onSwitchToMonitor?: () => void; onErrand?: () => void; onArchiveDone?: () => void; onWishlist?: () => void; onUsage?: () => void; } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function Header({ totalTaskCount, displayedCount, selectedCount, doneCount, readyCount, sortBy, maxConcurrent, hideDone, questTitle, hasRunningWave, }: { totalTaskCount: number; displayedCount: number; selectedCount: number; doneCount: number; readyCount: number; sortBy: SortField; maxConcurrent: number; hideDone: boolean; questTitle?: string; hasRunningWave?: boolean; }): React.ReactElement { return ( wombo-combo {hasRunningWave && ( <> ⚡ WAVE RUNNING )} {questTitle ? ( ▶ {questTitle} ) : ( Task Browser )} | {totalTaskCount} tasks {hideDone && ( <> ( {displayedCount} shown) )} | {selectedCount} selected {doneCount} done {readyCount} planned | Sort: {sortBy} | Concurrency: {maxConcurrent === 0 ? "∞" : String(maxConcurrent)} ); } function TaskListItem({ node, isSelected, isChecked, }: { node: TaskNode; isSelected: boolean; isChecked: boolean; }): React.ReactElement { const { task, depth, depsReady } = node; // Checkbox — ☑ means "planned" (queued for daemon), ☐ means backlog const checkbox = isChecked ? "☑" : "☐"; const checkColor = isChecked ? "cyan" : "gray"; // Readiness indicator const readyIcon = task.status === "done" ? "✓" : depsReady ? "●" : "○"; const readyColor = task.status === "done" ? "green" : depsReady ? "cyan" : "red"; // Indent based on depth const indent = " ".repeat(depth); // Status const sColor = TASK_STATUS_COLORS[task.status] ?? "white"; const sAbbr = TASK_STATUS_ABBREV[task.status] ?? task.status.slice(0, 4).toUpperCase(); // Priority const pColor = TASK_PRIORITY_COLORS[task.priority] ?? "white"; const pAbbr = PRIORITY_ABBREV[task.priority] ?? task.priority.slice(0, 4).toUpperCase(); return ( {isSelected ? "▸" : " "} {checkbox} {indent} {readyIcon} {task.id} {pAbbr} {sAbbr} ); } function TaskDetail({ node, usage, }: { node: TaskNode; usage?: UsageTotals; }): React.ReactElement { const { task, streamId, depth, dependedOnBy, depsReady } = node; const sColor = TASK_STATUS_COLORS[task.status] ?? "white"; const pColor = TASK_PRIORITY_COLORS[task.priority] ?? "white"; return ( {/* Title */} {task.title} {/* Basic info */} Status: {task.status} Priority: {task.priority} Difficulty: {task.difficulty} Effort: {task.effort} Completion: {task.completion}% Deps ready: {depsReady ? yes : no} Stream: {streamId} Depth: {depth} {/* Dependencies */} {task.depends_on.length > 0 && ( <> Dependencies: {task.depends_on.map((dep, i) => ( → {dep} ))} )} {/* Depended on by */} {dependedOnBy.length > 0 && ( <> Depended on by: {dependedOnBy.map((id, i) => ( → {id} ))} )} {/* Description */} {task.description && ( <> Description: {task.description} )} {/* Agent type */} {task.agent_type && ( <> Agent: {task.agent_type} )} {/* Token usage */} {usage && ( <> Token Usage: Input: {formatTokenCount(usage.input_tokens)} Output: {formatTokenCount(usage.output_tokens)} {usage.cache_read > 0 && ( Cache read: {formatTokenCount(usage.cache_read)} )} {usage.reasoning_tokens > 0 && ( Reasoning: {formatTokenCount(usage.reasoning_tokens)} )} Total: {formatTokenCount(usage.total_tokens)} {usage.total_cost > 0 && ( Cost: {formatCost(usage.total_cost)} )} Steps: {usage.record_count} )} {/* Constraints */} {task.constraints.length > 0 && ( <> Constraints: {task.constraints.map((c, i) => ( • {c} ))} )} ); } function StatusBar({ selectedCount, hideDone, hasRunningWave, hasBack, hasErrand, questId, }: { selectedCount: number; hideDone: boolean; hasRunningWave?: boolean; hasBack?: boolean; hasErrand?: boolean; questId?: boolean; }): React.ReactElement { return ( Keys: Space plan/unplan S stream A all +/- priority D {hideDone ? "show" : "hide"} done X archive done C concurrency {hasErrand && ( <> E errand )} W wishlist U usage O sort {hasRunningWave && ( <> Tab monitor )} {hasBack && ( <> Esc back )} Q {questId ? "back" : "quit"} {selectedCount > 0 ? ( {selectedCount} selected ) : ( Space to plan/unplan tasks — daemon picks up planned tasks automatically )} ); } // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- /** * TaskBrowserView — a declarative task browser component. * * Pure view: all data is passed in via props, all actions dispatched * via callbacks. The parent is responsible for loading tasks, building * the dependency graph, managing session state, and handling launches. */ export function TaskBrowserView(props: TaskBrowserViewProps): React.ReactElement { const { nodes, selectedIndex, selectedIds, sortBy, maxConcurrent, hideDone, totalTaskCount, doneCount, readyCount, taskUsage, questTitle, hasRunningWave, onSelectionChange, onToggle, onToggleStream, onToggleAll, onCycleSort, onChangePriority, onToggleDone, onCycleConcurrency, onQuit, onBack, onSwitchToMonitor, onErrand, onArchiveDone, onWishlist, onUsage, } = props; // Keyboard handling useInput((input, key) => { // Quit if (input === "q") { onQuit(); return; } // Navigate if ((key.downArrow || input === "j") && nodes.length > 0) { const next = Math.min(selectedIndex + 1, nodes.length - 1); onSelectionChange(next); return; } if ((key.upArrow || input === "k") && nodes.length > 0) { const prev = Math.max(selectedIndex - 1, 0); onSelectionChange(prev); return; } // Escape — back if (key.escape) { onBack?.(); return; } // Space — toggle selection if (input === " ") { onToggle(); return; } // Action keys if (input === "s") { onToggleStream(); return; } if (input === "a") { onToggleAll(); return; } if (input === "o") { onCycleSort(); return; } if (input === "d") { onToggleDone(); return; } if (input === "c") { onCycleConcurrency(); return; } if (input === "e") { onErrand?.(); return; } if (input === "w") { onWishlist?.(); return; } if (input === "u") { onUsage?.(); return; } if (input === "x") { onArchiveDone?.(); return; } if (input === "+" || input === "=") { onChangePriority(-1); return; } if (input === "-") { onChangePriority(1); return; } // Tab — switch to monitor or cycle sort if (key.tab) { if (hasRunningWave && onSwitchToMonitor) { onSwitchToMonitor(); } else { onCycleSort(); } return; } }); // Derived values const selectedCount = selectedIds.size; const displayedCount = nodes.length; // Selected task detail const selectedNode = nodes[selectedIndex] ?? null; const selectedTaskUsage = selectedNode && taskUsage ? taskUsage.get(selectedNode.task.id) : undefined; // Fill the entire terminal height for fullscreen rendering const { rows } = useTerminalSize(); return ( {/* Header */}
{/* Main body: list + detail */} {/* Task list (left pane, 60%) */} {nodes.map((node, i) => ( ))} {nodes.length === 0 && ( No tasks found )} {/* Detail pane (right pane, 40%) */} {selectedNode ? ( ) : ( No task selected )} {/* Status bar */} ); }