import { Key, type Component, type Focusable, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { Theme } from "@earendil-works/pi-coding-agent"; import { formatTodoActorSelector, type TodoActorRef, type TodoTask } from "../tools/todo.ts"; export interface TodoOverlayParams { getTasks: () => readonly TodoTask[]; requestRender: () => void; close: () => void; theme: Theme; } type TodoOverlayMode = "list" | "detail"; interface MemberScope { id: string; label: string; } export class TodoOverlay implements Component, Focusable { focused = false; private mode: TodoOverlayMode = "list"; private scopeIndex = 0; private selected = 0; private query = ""; private lastWidth = 80; constructor(private readonly params: TodoOverlayParams) {} invalidate(): void {} dispose(): void {} handleInput(data: string): void { if (matchesKey(data, Key.escape)) { if (this.mode === "detail") this.mode = "list"; else this.params.close(); this.params.requestRender(); return; } if (matchesKey(data, Key.left)) { this.moveScope(-1); return; } if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) { this.moveScope(1); return; } if (matchesKey(data, Key.up)) { this.moveSelection(-1); return; } if (matchesKey(data, Key.down)) { this.moveSelection(1); return; } if (matchesKey(data, Key.enter)) { if (this.selectedTask()) this.mode = "detail"; this.params.requestRender(); return; } if (matchesKey(data, Key.backspace)) { if (this.lastWidth < 20) return; this.query = this.query.slice(0, -1); this.selected = 0; this.params.requestRender(); return; } const input = printableInput(data); if (input) { if (this.lastWidth < 20) return; this.query += input; this.selected = 0; this.params.requestRender(); } } render(width: number): string[] { const safeWidth = Math.max(1, Math.min(width, 140)); this.lastWidth = safeWidth; if (safeWidth < 20) return [this.renderCompact(safeWidth)]; this.clampState(); if (this.mode === "detail" || safeWidth < 72) { return this.mode === "detail" ? this.renderDetail(safeWidth) : this.renderList(safeWidth); } return this.renderWide(safeWidth); } private renderCompact(width: number): string { const theme = this.params.theme; const task = this.selectedTask() ?? this.filteredTasks()[0]; const text = task ? `Esc · ${this.statusLabel(task.status)} · ${actorTag(task, this.tasks())} · ${task.subject}` : "Esc · Todo · no matching tasks"; return theme.bg("customMessageBg", pad(truncateToWidth(text, width, "…"), width)); } private renderList(width: number): string[] { const inner = width - 2; const tasks = this.filteredTasks(); const rows: string[] = [this.header(inner), this.separator(inner)]; const selectedRows = new Set(); if (tasks.length === 0) { rows.push(fitLine(`${this.statusLabel("pending")} · no matching Todo tasks`, inner)); } else { const start = visibleStart(this.selected, tasks.length, 8); for (let index = start; index < Math.min(tasks.length, start + 8); index++) { if (index === this.selected) selectedRows.add(rows.length); rows.push(this.taskRow(tasks[index], index === this.selected, inner)); } } rows.push(this.filterLine(inner, tasks.length)); rows.push(this.helpLine(inner)); return this.card(rows, width, selectedRows); } private renderWide(width: number): string[] { const inner = width - 2; const leftWidth = Math.max(30, Math.floor((inner - 3) * 0.52)); const rightWidth = inner - leftWidth - 3; const tasks = this.filteredTasks(); const selected = this.selectedTask(); const start = visibleStart(this.selected, tasks.length, 8); const left = tasks.length === 0 ? [fitLine(`${this.statusLabel("pending")} · no matching Todo tasks`, leftWidth)] : tasks.slice(start, start + 8).map((task, offset) => this.taskRow(task, start + offset === this.selected, leftWidth) ); const right = this.detailLines(selected, rightWidth); const rowCount = Math.max(left.length, right.length, 1); const rows: string[] = [this.header(inner), this.separator(inner)]; const selectedRows = new Set(); for (let index = 0; index < rowCount; index++) { if (tasks.length > 0 && start + index === this.selected) selectedRows.add(rows.length); rows.push(`${pad(left[index] ?? "", leftWidth)} │ ${pad(right[index] ?? "", rightWidth)}`); } rows.push(this.filterLine(inner, tasks.length)); rows.push(this.helpLine(inner)); return this.card(rows, width, selectedRows); } private renderDetail(width: number): string[] { const inner = width - 2; const task = this.selectedTask(); const rows: string[] = [fitLine(`Todo · ${this.currentScope().label} · task detail`, inner), this.separator(inner)]; rows.push(...this.detailLines(task, inner)); rows.push(this.helpLine(inner, ["Esc back", "←→ scope", "↑↓ task"])); return this.card(rows, width); } private detailLines(task: TodoTask | undefined, width: number): string[] { if (!task) return [fitLine(`${this.statusLabel("pending")} · no task selected`, width)]; const lines = [ fitLine(`#${task.id} · ${this.statusLabel(task.status)}`, width), fitLine(task.subject, width), fitLine(`Created @${actorLabel(task.createdBy, this.tasks())}`, width), fitLine(`Assigned @${actorLabel(task.assignee, this.tasks())}`, width), ]; if (task.description) lines.push(fitLine(`Description: ${task.description}`, width)); if (task.blockedBy.length) lines.push(fitLine(`Blocked by: ${task.blockedBy.join(", ")}`, width)); if (task.summary) lines.push(fitLine(`Summary: ${task.summary}`, width)); return lines; } private header(width: number): string { const theme = this.params.theme; const tasks = this.tasks(); const completed = tasks.filter((task) => task.status === "completed").length; const running = tasks.filter((task) => task.status === "in_progress").length; const scopes = this.scopes(); const scopeText = scopes.map((scope, index) => index === this.scopeIndex ? theme.bold(`[${scope.label}]`) : scope.label).join(" "); return fitLine(`Todo ${theme.fg("success", String(completed))}/${tasks.length} done · ${theme.fg("warning", String(running))} active · Scope: ${scopeText}`, width); } private filterLine(width: number, count: number): string { const query = this.query || "type to filter"; return fitLine(`Filter: ${query} · ${count} task${count === 1 ? "" : "s"}`, width); } private helpLine(width: number, segments?: string[]): string { return fitSegments(width, segments ?? ["Esc close", "Enter detail", "←→ scope", "↑↓ task", "type filter"]); } private statusLabel(status: TodoTask["status"]): string { const theme = this.params.theme; if (status === "in_progress") return theme.fg("warning", "▶ running"); if (status === "blocked") return theme.fg("error", "! blocked"); if (status === "completed") return theme.fg("success", "✓ completed"); if (status === "deleted") return theme.fg("dim", "⊘ deleted"); return theme.fg("dim", "□ pending"); } private separator(width: number): string { return this.params.theme.fg("borderMuted", "─".repeat(Math.max(1, width))); } /** Rounded card filled with a subtle panel background; the selected row is highlighted. */ private card(rows: string[], width: number, selectedRows: ReadonlySet = new Set()): string[] { const theme = this.params.theme; const edge = "─".repeat(Math.max(0, width - 2)); const border = (glyph: string) => theme.bg("customMessageBg", theme.fg("borderMuted", glyph)); const out: string[] = [border(`╭${edge}╮`)]; rows.forEach((row, index) => { const bg = selectedRows.has(index) ? "selectedBg" : "customMessageBg"; out.push(theme.bg(bg, pad(` ${row}`, width))); }); out.push(border(`╰${edge}╯`)); return out; } private taskRow(task: TodoTask, selected: boolean, width: number): string { const subject = task.status === "in_progress" ? this.params.theme.bold(task.subject) : task.subject; return fitLine(`${selected ? "›" : " "} ${this.statusLabel(task.status)} · ${actorTag(task, this.tasks())} · ${subject}`, width); } private moveScope(delta: number): void { const scopes = this.scopes(); this.scopeIndex = wrapIndex(this.scopeIndex + delta, scopes.length); // Keep the cursor highlight on the closest task instead of jumping to the top. this.selected = clampIndex(this.selected, this.filteredTasks().length); this.mode = "list"; this.params.requestRender(); } private moveSelection(delta: number): void { const tasks = this.filteredTasks(); this.selected = wrapIndex(this.selected + delta, tasks.length); this.params.requestRender(); } private clampState(): void { const scopes = this.scopes(); this.scopeIndex = clampIndex(this.scopeIndex, scopes.length); this.selected = clampIndex(this.selected, this.filteredTasks().length); } private tasks(): TodoTask[] { return this.params.getTasks().filter((task) => !task.origin && task.status !== "deleted") as TodoTask[]; } private scopes(): MemberScope[] { const actors = new Map(); for (const task of this.tasks()) { actors.set(task.createdBy.id, task.createdBy); actors.set(task.assignee.id, task.assignee); } const members = [...actors.values()].sort((left, right) => left.kind === right.kind ? left.label.localeCompare(right.label) : left.kind === "root" ? -1 : 1 ); return [ { id: "*", label: "All" }, ...members.map((actor) => ({ id: actor.id, label: actorLabel(actor, this.tasks()) })), ]; } private currentScope(): MemberScope { return this.scopes()[this.scopeIndex] ?? { id: "*", label: "All" }; } private filteredTasks(): TodoTask[] { const scope = this.currentScope(); const query = this.query.trim().toLocaleLowerCase(); const priority: Record = { in_progress: 0, blocked: 1, pending: 2, completed: 3 }; return this.tasks() .filter((task) => scope.id === "*" || task.createdBy.id === scope.id || task.assignee.id === scope.id) .filter((task) => !query || [ task.id, task.subject, task.description ?? "", task.createdBy.label, task.assignee.label, ].some((value) => value.toLocaleLowerCase().includes(query))) .sort((left, right) => (priority[left.status] ?? 4) - (priority[right.status] ?? 4) || left.createdAt - right.createdAt ); } private selectedTask(): TodoTask | undefined { return this.filteredTasks()[this.selected]; } } function actorTag(task: TodoTask, tasks: readonly TodoTask[]): string { const created = actorLabel(task.createdBy, tasks); const assigned = actorLabel(task.assignee, tasks); return task.createdBy.id === task.assignee.id ? `@${assigned}` : `@${created}→@${assigned}`; } function actorLabel(actor: TodoActorRef, tasks: readonly TodoTask[]): string { return formatTodoActorSelector(actor, tasks.flatMap((task) => [task.createdBy, task.assignee])); } function printableInput(data: string): string { const input = decodeKittyPrintable(data) ?? data; return input.length > 0 && !input.includes("\x1b") && [...input].every((char) => char >= " " && char !== "\x7f") ? input : ""; } function visibleStart(selected: number, length: number, size: number): number { return Math.max(0, Math.min(selected - Math.floor(size / 2), Math.max(0, length - size))); } function wrapIndex(index: number, length: number): number { return length === 0 ? 0 : (index + length) % length; } function clampIndex(index: number, length: number): number { return length === 0 ? 0 : Math.max(0, Math.min(index, length - 1)); } function fitLine(value: string, width: number): string { return truncateToWidth(value, Math.max(1, width), "…"); } function fitSegments(width: number, segments: readonly string[]): string { const kept: string[] = []; for (const segment of segments) { const candidate = [...kept, segment].join(" · "); if (visibleWidth(candidate) > width) break; kept.push(segment); } return kept.length ? kept.join(" · ") : fitLine(segments[0] ?? "", width); } function pad(value: string, width: number): string { const fitted = fitLine(value, width); return `${fitted}${" ".repeat(Math.max(0, width - visibleWidth(fitted)))}`; }