import { DynamicBorder, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { Container, Key, Spacer, Text, matchesKey, truncateToWidth, visibleWidth, type Component, } from "@mariozechner/pi-tui"; import { buildPrimaryHelpText, buildSecondaryHelpText, getHeaderStatus, isSameDraft, normalizeDraft, type FormDraft, type FormFocus, type FormMode, type HeaderStatus, } from "../../controllers/show.ts"; import { buildTaskIdentityText, buildTaskListTextParts, formatTaskTypeCode, type Task, type TaskStatus, } from "../../models/task.ts"; import { BlurEditorField } from "../components/blur-editor.ts"; import { KEYBOARD_HELP_PADDING_X, formatKeyboardHelp } from "../components/keyboard-help.ts"; import { MinHeightContainer } from "../components/min-height.ts"; export type TaskFormAction = "back" | "close_list"; export interface TaskFormResult { action: TaskFormAction; } interface ShowTaskFormOptions { mode: FormMode; subtitle: string; task: Task; closeKey: string; cycleStatus: (status: TaskStatus) => TaskStatus; cycleTaskType: (taskType: string | undefined) => string; parsePriorityKey: (data: string) => string | null; priorities: string[]; priorityHotkeys?: Record; onSave: (draft: FormDraft) => Promise; } function buildPageTitle(theme: any, subtitle: string, status?: HeaderStatus): string { const base = `${theme.fg("muted", theme.bold("Tasks"))}${theme.fg("dim", ` • ${subtitle}`)}`; if (!status) return base; const marker = status.icon ? theme.fg(status.color, status.icon) : "•"; return `${base} ${marker} ${theme.fg(status.color, status.message)}`; } function buildSelectedTaskLine( mode: FormMode, theme: any, rowIdentity: string, rowMeta: string, priority: string | undefined, taskType: string | undefined ): string { if (mode === "create") { const identity = buildTaskIdentityText(priority, "new"); return `${theme.fg("accent", SELECTED_ITEM_PREFIX)}${identity} ${formatTaskTypeCode(taskType)}`; } return `${theme.fg("accent", SELECTED_ITEM_PREFIX)}${rowIdentity} ${rowMeta}`; } function fieldLabel(theme: any, label: string, focused: boolean): string { const color = focused ? "accent" : "muted"; return theme.fg(color, theme.bold(` ${label}`)); } const SELECTED_ITEM_PREFIX = "› "; const DESCRIPTION_FIELD_HEIGHT = 8; const PAGE_CONTENT_MIN_HEIGHT = 19; class FixedHeightField implements Component { constructor( private child: Component, private height: number ) {} invalidate(): void { this.child.invalidate(); } render(width: number): string[] { const lines = this.child.render(width); if (lines.length === this.height) return lines; if (lines.length < this.height) { return [...lines, ...Array(this.height - lines.length).fill(" ".repeat(Math.max(0, width)))]; } if (this.height <= 1) { return [lines[lines.length - 1] || ""]; } const bottomLine = lines[lines.length - 1] || ""; const bodyLines = lines.slice(0, lines.length - 1); const viewportHeight = this.height - 1; const cursorIndex = bodyLines.findIndex((line) => line.includes("\x1b[7m")); let start = Math.max(0, bodyLines.length - viewportHeight); if (cursorIndex >= 0) { if (cursorIndex < start) { start = cursorIndex; } else if (cursorIndex >= start + viewportHeight) { start = cursorIndex - viewportHeight + 1; } } const clippedBody = bodyLines.slice(start, start + viewportHeight); if (clippedBody.length < viewportHeight) { clippedBody.push( ...Array(viewportHeight - clippedBody.length).fill(" ".repeat(Math.max(0, width))) ); } return [...clippedBody, bottomLine]; } handleInput(data: string): void { const childWithInput = this.child as Component & { handleInput?: (input: string) => void }; childWithInput.handleInput?.(data); } } class ReservedLineText implements Component { private text = ""; constructor(private paddingX = 1) {} setText(text: string): void { this.text = text; } invalidate(): void {} render(width: number): string[] { const innerWidth = Math.max(0, width - this.paddingX * 2); const left = " ".repeat(this.paddingX); const right = " ".repeat(this.paddingX); if (!this.text || this.text.trim().length === 0) { return [`${left}${" ".repeat(innerWidth)}${right}`]; } const content = truncateToWidth(this.text, innerWidth); const trailingPadding = Math.max(0, innerWidth - visibleWidth(content)); return [`${left}${content}${" ".repeat(trailingPadding)}${right}`]; } } export async function showTaskForm( ctx: ExtensionCommandContext, options: ShowTaskFormOptions ): Promise { const { mode, subtitle, task, closeKey, cycleStatus, cycleTaskType, parsePriorityKey, priorities, priorityHotkeys, onSave, } = options; let taskTypeValue = task.taskType; let titleValue = task.title; let descValue = task.description ?? ""; let statusValue = task.status; let priorityValue = task.priority; return ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => { const container = new Container(); const headerContainer = new Container(); const formContainer = new Container(); const footerContainer = new Container(); const paddedFormContainer = new MinHeightContainer(formContainer, PAGE_CONTENT_MIN_HEIGHT); container.addChild(headerContainer); container.addChild(paddedFormContainer); container.addChild(footerContainer); const pageTitleText = new Text("", 1, 0); const selectedTaskText = new Text("", 0, 0); const titleLabel = new Text("", 0, 0); const descLabel = new Text("", 0, 0); const helpText = new ReservedLineText(KEYBOARD_HELP_PADDING_X); const shortcutsText = new ReservedLineText(KEYBOARD_HELP_PADDING_X); let focus: FormFocus = mode === "create" ? "title" : "nav"; let saveIndicator: "saving" | "saved" | "error" | undefined; let saveIndicatorTimer: ReturnType | undefined; let saving = false; let disposed = false; const editorTheme = { borderColor: (s: string) => theme.fg("accent", s), selectList: { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }, }; const titleEditor = new BlurEditorField(tui, editorTheme, { stripTopBorder: true, blurredBorderColor: (s: string) => theme.fg("muted", s), paddingX: 2, indentX: 2, }); titleEditor.setText(titleValue); titleEditor.disableSubmit = true; titleEditor.onChange = (text: string) => { const normalized = text.replace(/\r?\n/g, " "); if (normalized !== text) { titleEditor.setText(normalized); return; } titleValue = normalized; }; const descEditor = new BlurEditorField(tui, editorTheme, { stripTopBorder: true, blurredBorderColor: (s: string) => theme.fg("muted", s), paddingX: 2, indentX: 2, }); const descEditorField = new FixedHeightField(descEditor, DESCRIPTION_FIELD_HEIGHT); descEditor.setText(descValue); descEditor.disableSubmit = true; descEditor.onChange = (text: string) => { descValue = text; }; const currentDraft = (): FormDraft => ({ title: titleValue, description: descValue, status: statusValue, priority: priorityValue, taskType: taskTypeValue, }); let lastSavedDraft: FormDraft = currentDraft(); const triggerSave = async () => { if (saving || disposed) return; const draft = currentDraft(); if (isSameDraft(draft, lastSavedDraft)) return; saving = true; if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer); saveIndicator = "saving"; renderLayout(); try { const didSave = await onSave(draft); if (disposed) return; if (!didSave) { saveIndicator = undefined; return; } lastSavedDraft = normalizeDraft(draft); saveIndicator = "saved"; } catch (e) { if (disposed) return; saveIndicator = "error"; ctx.ui.notify(e instanceof Error ? e.message : String(e), "error"); } finally { saving = false; if (!disposed) renderLayout(); } if (saveIndicator === "saved" && !disposed) { saveIndicatorTimer = setTimeout(() => { if (disposed) return; saveIndicator = undefined; renderLayout(); }, 5000); } }; const renderLayout = () => { titleEditor.focused = focus === "title"; descEditor.focused = focus === "desc"; const rowParts = buildTaskListTextParts({ ...task, title: titleValue, description: descValue, status: statusValue, priority: priorityValue, taskType: taskTypeValue, }); const headerStatus = getHeaderStatus(saveIndicator, focus); pageTitleText.setText(buildPageTitle(theme, subtitle, headerStatus)); selectedTaskText.setText( buildSelectedTaskLine( mode, theme, rowParts.identity, rowParts.meta, priorityValue, taskTypeValue ) ); titleLabel.setText(fieldLabel(theme, "Title", focus === "title")); descLabel.setText(fieldLabel(theme, "Description", focus === "desc")); helpText.setText(formatKeyboardHelp(theme, buildPrimaryHelpText(focus))); const secondaryHelp = buildSecondaryHelpText(focus, priorities, priorityHotkeys); shortcutsText.setText(secondaryHelp ? formatKeyboardHelp(theme, secondaryHelp) : ""); container.invalidate(); tui.requestRender(); }; headerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); headerContainer.addChild(pageTitleText); headerContainer.addChild(selectedTaskText); formContainer.addChild(new Spacer(1)); formContainer.addChild(titleLabel); formContainer.addChild(titleEditor); formContainer.addChild(new Spacer(1)); formContainer.addChild(descLabel); formContainer.addChild(descEditorField); 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))); renderLayout(); const requestRender = () => { container.invalidate(); tui.requestRender(); }; const handleTitleInput = (data: string) => { if (matchesKey(data, Key.enter)) { focus = "nav"; void triggerSave(); renderLayout(); return; } if (matchesKey(data, Key.tab)) { focus = "desc"; renderLayout(); return; } titleEditor.handleInput(data); requestRender(); }; const handleDescInput = (data: string) => { if (matchesKey(data, Key.enter)) { descEditor.insertTextAtCursor("\n"); requestRender(); return; } if (matchesKey(data, Key.tab)) { focus = "nav"; void triggerSave(); renderLayout(); return; } descEditor.handleInput(data); requestRender(); }; const handleNavInput = (data: string) => { if (matchesKey(data, Key.enter)) { void triggerSave(); return; } if (matchesKey(data, Key.tab)) { focus = "title"; renderLayout(); return; } if ( matchesKey(data, Key.escape) || matchesKey(data, Key.left) || data === "q" || data === "Q" ) { done({ action: "back" }); return; } if (data === "t" || data === "T") { taskTypeValue = cycleTaskType(taskTypeValue); renderLayout(); return; } if (data === " ") { statusValue = cycleStatus(statusValue); renderLayout(); return; } const priority = parsePriorityKey(data); if (priority !== null) { priorityValue = priority; renderLayout(); } }; return { render: (w: number) => container.render(w).map((line: string) => truncateToWidth(line, w)), invalidate: () => container.invalidate(), dispose: () => { disposed = true; if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer); }, handleInput: (data: string) => { if (data === closeKey) { done({ action: "close_list" }); return; } if (focus !== "nav" && matchesKey(data, Key.escape)) { focus = "nav"; renderLayout(); return; } if (focus === "title") { handleTitleInput(data); return; } if (focus === "desc") { handleDescInput(data); return; } handleNavInput(data); }, }; }); }