import { DynamicBorder, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { Container, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { toKebabCase, type Task, type TaskStatus } from "../../models/task.ts"; import type { TaskUpdate } from "../../backend/api.ts"; import { DESCRIPTION_PART_SEPARATOR, buildListRowModel, decodeDescription, stripAnsi, } from "../../models/list-item.ts"; import { buildListPrimaryHelpText, buildListSecondaryHelpText, resolveListIntent, } from "../../controllers/list.ts"; import { KEYBOARD_HELP_PADDING_X, formatKeyboardHelp } from "../components/keyboard-help.ts"; import { MinHeightContainer } from "../components/min-height.ts"; import { SelectListWithColumns } from "../components/select-list-with-columns.ts"; const LIST_PAGE_CONTENT_MIN_HEIGHT = 20; const TASK_LIST_ROW_LAYOUT = { valueMaxWidth: 60, valueColumnWidth: 62, }; export interface ListPageConfig { title: string; subtitle?: string; tasks: Task[]; allowPriority?: boolean; allowSearch?: boolean; filterTerm?: string; priorities: string[]; priorityHotkeys?: Record; closeKey: string; cycleStatus: (status: TaskStatus) => TaskStatus; cycleTaskType: (current: string | undefined) => string; onUpdateTask: (ref: string, update: TaskUpdate) => Promise; onWork: (task: Task) => void; onInsert: (task: Task) => void; onEdit: ( ref: string, task: Task | undefined ) => Promise<{ updatedTask: Task | null; closeList: boolean }>; onCreate: () => Promise; } function truncateDescription(desc: string | undefined, maxLines: number): string[] { if (!desc || !desc.trim()) return ["(no description)"]; const allLines = desc.split(/\r?\n/); const lines = allLines.slice(0, maxLines); if (allLines.length > maxLines) lines.push("..."); return lines; } function matchesFilter(task: Task, term: string): boolean { const lower = term.toLowerCase(); return ( task.title.toLowerCase().includes(lower) || (task.description ?? "").toLowerCase().includes(lower) || (task.id ?? "").toLowerCase().includes(lower) || toKebabCase(task.status).includes(lower) ); } function buildHeaderText( theme: any, title: string, subtitle: string | undefined, searching: boolean, searchBuffer: string, filterTerm: string ): string { if (searching) return theme.fg("muted", theme.bold(`Search: ${searchBuffer}_`)); if (filterTerm) return theme.fg("muted", theme.bold(`${title} [filter: ${filterTerm}]`)); const subtitlePart = subtitle ? theme.fg("dim", ` • ${subtitle}`) : ""; return `${theme.fg("muted", theme.bold(title))}${subtitlePart}`; } export async function showTaskList( ctx: ExtensionCommandContext, config: ListPageConfig ): Promise { const { title, subtitle, tasks, allowPriority = true, allowSearch = true } = config; const displayTasks = [...tasks]; let filterTerm = config.filterTerm || ""; let rememberedSelectedRef: string | undefined; while (true) { const visible = filterTerm ? displayTasks.filter((i) => matchesFilter(i, filterTerm)) : displayTasks; if (visible.length === 0 && filterTerm) { ctx.ui.notify(`No matches for "${filterTerm}"`, "warning"); filterTerm = ""; continue; } const getMaxLabelWidth = () => Math.max(...displayTasks.map((i) => stripAnsi(buildListRowModel(i).label).length)); let selectedRef: string | undefined; const result = await ctx.ui.custom<"cancel" | "select" | "create">( (tui: any, theme: any, _kb: any, done: any) => { const container = new Container(); let searching = false; let searchBuffer = ""; let descScroll = 0; const headerContainer = new Container(); const listAreaContainer = new Container(); const footerContainer = new Container(); const paddedListAreaContainer = new MinHeightContainer( listAreaContainer, LIST_PAGE_CONTENT_MIN_HEIGHT ); container.addChild(headerContainer); container.addChild(paddedListAreaContainer); container.addChild(footerContainer); const titleText = new Text("", 1, 0); const META_SUMMARY_SEPARATOR = " "; const accentMarker = "__ACCENT_MARKER__"; const accentedMarker = theme.fg("accent", accentMarker); const markerIndex = accentedMarker.indexOf(accentMarker); const accentPrefix = markerIndex >= 0 ? accentedMarker.slice(0, markerIndex) : ""; const accentSuffix = markerIndex >= 0 ? accentedMarker.slice(markerIndex + accentMarker.length) : "\x1b[0m"; const applyAccentWithAnsi = (text: string) => { const normalized = text.replaceAll(DESCRIPTION_PART_SEPARATOR, META_SUMMARY_SEPARATOR); if (!accentPrefix) return theme.fg("accent", normalized); return `${accentPrefix}${normalized.replace(/\x1b\[0m/g, `\x1b[0m${accentPrefix}`)}${accentSuffix}`; }; const styleDescription = (text: string) => { const { meta, summary } = decodeDescription(text); if (!summary) return theme.fg("muted", meta); return `${theme.fg("muted", meta)}${META_SUMMARY_SEPARATOR}${summary}`; }; const getItems = () => { const filtered = filterTerm ? displayTasks.filter((i) => matchesFilter(i, filterTerm)) : displayTasks; const maxLabelWidth = getMaxLabelWidth(); return filtered.map((task) => { const row = buildListRowModel(task, { maxLabelWidth }); return { value: row.ref, label: row.label, description: row.description, }; }); }; const selectListTheme = { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => applyAccentWithAnsi(t), description: (t: string) => styleDescription(t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }; let items = getItems(); let selectList = new SelectListWithColumns( items, Math.min(items.length, 10), selectListTheme, TASK_LIST_ROW_LAYOUT ); if (rememberedSelectedRef) { const rememberedIndex = items.findIndex((i) => i.value === rememberedSelectedRef); if (rememberedIndex >= 0) selectList.setSelectedIndex(rememberedIndex); } selectList.onSelectionChange = () => { const selected = selectList.getSelectedItem(); if (selected) rememberedSelectedRef = selected.value; updateDescPreview(); tui.requestRender(); }; selectList.onSelect = () => { const sel = selectList.getSelectedItem(); if (sel) { selectedRef = sel.value; rememberedSelectedRef = sel.value; } done("select"); }; selectList.onCancel = () => { if (filterTerm) { filterTerm = ""; rebuildAndRender(); } else { done("cancel"); } }; const renderListArea = () => { while (listAreaContainer.children.length > 0) { listAreaContainer.removeChild(listAreaContainer.children[0]); } listAreaContainer.addChild(selectList); listAreaContainer.addChild(new Spacer(1)); listAreaContainer.addChild(itemPreviewContainer); }; const wrapText = (text: string, width: number, maxLines: number): string[] => { const lines: string[] = []; const safeWidth = Math.max(1, width); if (text.length === 0) return [""]; const words = text.split(" "); let currentLine = ""; const flushLine = () => { if (lines.length < maxLines) lines.push(currentLine); currentLine = ""; }; for (const word of words) { const candidate = currentLine ? `${currentLine} ${word}` : word; if (stripAnsi(candidate).length <= safeWidth) { currentLine = candidate; continue; } if (currentLine) { flushLine(); if (lines.length >= maxLines) break; } let remaining = word; while (stripAnsi(remaining).length > safeWidth) { const chunk = remaining.slice(0, safeWidth); if (lines.length < maxLines) lines.push(chunk); if (lines.length >= maxLines) break; remaining = remaining.slice(safeWidth); } if (lines.length >= maxLines) break; currentLine = remaining; } if (currentLine && lines.length < maxLines) lines.push(currentLine); return lines.slice(0, maxLines); }; const buildDescText = (descLines: string[], width: number): string => { const wrappedLines: string[] = []; for (const line of descLines) { const wrapped = wrapText(line, width, 7 - wrappedLines.length); wrappedLines.push(...wrapped); if (wrappedLines.length >= 7) break; } while (wrappedLines.length < 7) wrappedLines.push(""); return wrappedLines.join("\n"); }; const previewTitleText = new Text("", 0, 0); const descTextComponent = new Text(buildDescText([], 80), 0, 0); const itemPreviewContainer = new Container(); itemPreviewContainer.addChild(previewTitleText); itemPreviewContainer.addChild(descTextComponent); let lastWidth = 80; const updateDescPreview = () => { const selected = selectList.getSelectedItem(); if (!selected) { previewTitleText.setText(""); descTextComponent.setText(buildDescText([], lastWidth)); return; } descScroll = 0; const task = displayTasks.find((i) => i.ref === selected.value); if (!task) { previewTitleText.setText(""); descTextComponent.setText(buildDescText([], lastWidth)); return; } previewTitleText.setText(theme.fg("accent", theme.bold(task.title))); const descLines = truncateDescription(task.description, 100); descTextComponent.setText(buildDescText(descLines, lastWidth)); }; if (items[0]) updateDescPreview(); headerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); headerContainer.addChild(titleText); const helpText = new Text("", KEYBOARD_HELP_PADDING_X, 0); const shortcutsText = new Text( formatKeyboardHelp(theme, buildListSecondaryHelpText()), KEYBOARD_HELP_PADDING_X, 0 ); footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); footerContainer.addChild(helpText); footerContainer.addChild(shortcutsText); footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); renderListArea(); const refreshDisplay = () => { titleText.setText( buildHeaderText(theme, title, subtitle, searching, searchBuffer, filterTerm) ); helpText.setText( formatKeyboardHelp( theme, buildListPrimaryHelpText({ searching, filtered: !!filterTerm, allowPriority, allowSearch, closeKey: config.closeKey, priorities: config.priorities, priorityHotkeys: config.priorityHotkeys, }) ) ); }; refreshDisplay(); const moveSelection = (delta: number) => { if (items.length === 0) return; const selected = selectList.getSelectedItem(); const currentIndex = selected ? items.findIndex((i) => i.value === selected.value) : 0; const normalizedIndex = currentIndex >= 0 ? currentIndex : 0; const nextIndex = (normalizedIndex + delta + items.length) % items.length; selectList.setSelectedIndex(nextIndex); updateDescPreview(); container.invalidate(); tui.requestRender(); }; const getSelectedTask = (): Task | undefined => { const selected = selectList.getSelectedItem(); if (!selected) return undefined; rememberedSelectedRef = selected.value; return displayTasks.find((i) => i.ref === selected.value); }; const withSelectedTask = (run: (task: Task) => void): void => { const task = getSelectedTask(); if (!task) return; run(task); }; const rebuildAndRender = () => { items = getItems(); const prevSelected = selectList.getSelectedItem(); selectList = new SelectListWithColumns( items, Math.min(items.length, 10), selectListTheme, TASK_LIST_ROW_LAYOUT ); selectList.onSelectionChange = () => { const selected = selectList.getSelectedItem(); if (selected) rememberedSelectedRef = selected.value; updateDescPreview(); tui.requestRender(); }; selectList.onSelect = () => { const sel = selectList.getSelectedItem(); if (sel) { selectedRef = sel.value; rememberedSelectedRef = sel.value; } done("select"); }; selectList.onCancel = () => { if (filterTerm) { filterTerm = ""; rebuildAndRender(); } else { done("cancel"); } }; renderListArea(); if (prevSelected) { const newIdx = items.findIndex((i) => i.value === prevSelected.value); if (newIdx >= 0) selectList.setSelectedIndex(newIdx); } refreshDisplay(); updateDescPreview(); container.invalidate(); tui.requestRender(); }; return { render: (w: number) => { lastWidth = w; return container.render(w).map((l: string) => truncateToWidth(l, w)); }, invalidate: () => container.invalidate(), handleInput: (data: string) => { const intent = resolveListIntent(data, { searching, filtered: !!filterTerm, allowSearch, allowPriority, closeKey: config.closeKey, priorities: config.priorities, priorityHotkeys: config.priorityHotkeys, }); switch (intent.type) { case "cancel": done("cancel"); return; case "searchStart": searching = true; searchBuffer = ""; refreshDisplay(); container.invalidate(); tui.requestRender(); return; case "searchCancel": searching = false; searchBuffer = ""; refreshDisplay(); container.invalidate(); tui.requestRender(); return; case "searchApply": filterTerm = searchBuffer.trim(); searching = false; rebuildAndRender(); refreshDisplay(); return; case "searchBackspace": searchBuffer = searchBuffer.slice(0, -1); refreshDisplay(); container.invalidate(); tui.requestRender(); return; case "searchAppend": searchBuffer += intent.value; refreshDisplay(); container.invalidate(); tui.requestRender(); return; case "moveSelection": moveSelection(intent.delta); return; case "work": withSelectedTask((task) => { done("cancel"); config.onWork(task); }); return; case "edit": withSelectedTask((task) => { selectedRef = task.ref; done("select"); }); return; case "toggleStatus": withSelectedTask((task) => { const newStatus = config.cycleStatus(task.status); task.status = newStatus; void config.onUpdateTask(task.ref, { status: newStatus }); rebuildAndRender(); }); return; case "setPriority": withSelectedTask((task) => { if (task.priority === intent.priority) return; task.priority = intent.priority; void config.onUpdateTask(task.ref, { priority: intent.priority }); rebuildAndRender(); }); return; case "scrollDescription": withSelectedTask((task) => { const descLines = truncateDescription(task.description, 100); const allWrapped: string[] = []; for (const line of descLines) { const wrapped = wrapText(line, lastWidth, 100); allWrapped.push(...wrapped); } const maxScroll = Math.max(0, allWrapped.length - 7); if (intent.delta > 0 && descScroll < maxScroll) { descScroll++; } else if (intent.delta < 0 && descScroll > 0) { descScroll--; } const visible = allWrapped.slice(descScroll, descScroll + 7); while (visible.length < 7) visible.push(""); descTextComponent.setText(visible.join("\n")); container.invalidate(); tui.requestRender(); }); return; case "toggleType": withSelectedTask((task) => { const newType = config.cycleTaskType(task.taskType); task.taskType = newType; void config.onUpdateTask(task.ref, { taskType: newType }); rebuildAndRender(); }); return; case "create": done("create"); return; case "insert": withSelectedTask((task) => { done("cancel"); config.onInsert(task); }); return; case "delegate": selectList.handleInput(data); tui.requestRender(); return; } }, }; } ); if (result === "cancel") return; if (result === "create") { const createdTask = await config.onCreate(); if (createdTask) { displayTasks.unshift(createdTask); rememberedSelectedRef = createdTask.ref; } continue; } if (result === "select" && selectedRef) { rememberedSelectedRef = selectedRef; const currentTask = displayTasks.find((i) => i.ref === selectedRef); const editResult = await config.onEdit(selectedRef, currentTask); if (editResult.updatedTask) { const idx = displayTasks.findIndex((i) => i.ref === selectedRef); if (idx !== -1) displayTasks[idx] = editResult.updatedTask; } if (editResult.closeList) return; } } }