import { matchesKey } from "@earendil-works/pi-tui"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { cloneGoal, createGoal, nowIso, type GoalTask } from "./goal-record.ts"; import { checkSubtasksComplete, findTaskInTree } from "./goal-policy.ts"; import { DEFAULT_GOAL_KEYBINDINGS, loadGoalSettings } from "./goal-settings.ts"; import { serializeGoalFile } from "./storage/goal-files.ts"; import type { AuditorWidgetProgress } from "./widgets/goal-widget.ts"; import type { GoalCore } from "./goal-state.ts"; const DEBUG_GOALS_DIR = ".pi/goals/debug"; /** * Terminal input keybindings (Escape pause/abort-audit, Ctrl+Shift+T dashboard * overlay, and the hidden debug-mode bindings) plus the debug goal/task/audit * helpers. Re-registered at session start/tree navigation; the handler reads * live state through the core at event time. */ let debugGoalCounter = 0; let debugMockAuditTimer: ReturnType | null = null; /** Debug helpers are inert unless PI_GOAL_DEBUG is set (P1-13). */ function isDebugEnabled(): boolean { const value = process.env.PI_GOAL_DEBUG; return value === "true" || value === "1"; } /** * F3: toggle one task through the goal-service mutation boundary with the * same gates as update_goal_task: completing a pending task requires its * subtasks complete first and (unless contracts are disabled) evidence when * it carries a verification contract — the evidence is collected through the * UI input dialog; completing a complete task reopens it to pending (a * deliberate TUI-only relaxation of the tool surface's immutability, for * explicit human corrections). */ export async function toggleTaskViaService(core: GoalCore, ctx: ExtensionContext, goalId: string, taskId: string): Promise<{ ok: boolean; message?: string }> { const goal = core.goalsById.get(goalId); if (!goal) return { ok: false, message: `Goal ${goalId} not found in this session.` }; if (goal.status !== "active") return { ok: false, message: `Task updates apply only to an active goal (current status: ${goal.status}).` }; if (!goal.taskList) return { ok: false, message: "The goal has no task list." }; const settings = loadGoalSettings(ctx.cwd); const task = findTaskInTree(goal.taskList.tasks, taskId); if (!task) return { ok: false, message: `Task "${taskId}" not found.` }; const now = nowIso(); if (task.status === "pending") { let evidence: string | undefined; if (!settings.disableContracts && task.verificationContract) { const input = await ctx.ui.input(`Evidence for task ${taskId}`, ""); if (input === undefined || !input.trim()) { return { ok: false, message: `Task "${taskId}" has a verification contract; provide evidence to complete it.` }; } evidence = input.trim().slice(0, 200); } const subtaskGate = checkSubtasksComplete(task); if (subtaskGate) return { ok: false, message: subtaskGate }; const result = core.goalService.updateTask(ctx, { focusToken: core.focusedOperationToken(goalId), taskId, validate: (t) => { if (t.status === "complete") return { ok: false, message: `Task "${taskId}" is already complete.` }; return { ok: true }; }, update: (t) => ({ ...t, status: "complete" as const, completedAt: now, evidence }), ledger: (written) => [{ type: "task_complete", goalId: written.id, taskId, evidence, at: written.updatedAt }], }); if (!result.ok) return { ok: false, message: result.message }; return { ok: true }; } if (task.status === "complete") { const result = core.goalService.updateTask(ctx, { focusToken: core.focusedOperationToken(goalId), taskId, validate: () => ({ ok: true }), update: (t) => ({ ...t, status: "pending" as const, completedAt: undefined, evidence: undefined }), ledger: (written) => [{ type: "task_reopened", goalId: written.id, taskId, at: written.updatedAt }], }); if (!result.ok) return { ok: false, message: result.message }; return { ok: true }; } return { ok: false, message: `Task "${taskId}" is skipped; change it with /goal-tweak or update_goal_task.` }; } export function syncTerminalInputPause(core: GoalCore, ctx: ExtensionContext): void { if (!ctx.hasUI) return; core.terminalInputUnsubscribe?.(); const settings = loadGoalSettings(typeof ctx.cwd === "string" ? ctx.cwd : process.cwd()); const keybindings = settings.keybindings?.dashboard ?? DEFAULT_GOAL_KEYBINDINGS.dashboard; core.terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => { // If an audit is running, Escape aborts the audit instead of pausing. // Must return { consume: true } so the TUI doesn't also process the key // and abort the running tool execution, which would cascade into pausing // the entire goal (agent_end sees ctx.signal?.aborted and calls pauseActiveGoal). // By contrast, the live-goal pause branch below deliberately does NOT // consume: Escape must pass back to pi so it aborts the running tool // execution and stops the current turn — pausing without stopping the // "working" is exactly the reported bug. // Any goal-owned modal (questionnaire, task confirmation, settings, // goal picker, task-list overlay, escape dialog) owns every key while // it is open: never intercept — otherwise Escape would pause the goal // before the dialog could process it (bn-l pattern). Depth counter so // nested goal modals remain guarded. The same applies to any other // extension's TUI overlay (pi-subagents fleet inspector, ...): while an // overlay is visible, Escape belongs to the focused overlay component, // not to the goal — intercepting it would pause the goal while the user // is merely closing the overlay. And it applies to every blocking // `ctx.ui.*` dialog, including foreign non-overlay dialogs (select / // confirm / input / editor / custom without `overlay: true`), which // replace the editor and are therefore invisible to hasOverlay(): // pi core reports them through ui_prompt_start/ui_prompt_end and // goal-events.ts tracks that as core.uiPromptDepth. Reporting an // Escape here would cancel a running audit (or pause the goal and // abort the current turn) when the user only meant to close that // window. pi core's own editor-replacing selectors (/model, /tree, // /settings, session picker) emit no ui_prompt span and remain a known // residual gap, documented in specs/2026-09-12-escape-foreign-ui-prompt-guard. if (core.goalModalDepth > 0 || core.uiPromptDepth > 0 || core.goalTui?.hasOverlay?.()) return undefined; if (matchesKey(data, "escape") && core.auditProgress) { core.abortAudit(ctx); return { consume: true }; } // §10: Escape collapses the expanded dashboard before it can pause the // goal; Ctrl+Shift+T toggles compact/expanded (§19.5). While the // expanded dashboard is open it owns the plain arrow keys; the // compact widget scrolls with Ctrl+Shift chords that pi never binds // (the editor owns ↑/↓/PgUp/PgDn), so no focus state is needed and // editor keybindings are untouched whenever the dashboard is not // focused. if (matchesKey(data, "escape") && core.isDashboardExpanded()) { core.toggleDashboardExpanded(); return { consume: true }; } // Escape on a live goal: pause it, then pass the key back to pi by // returning undefined (not { consume: true }) so pi aborts the running // tool execution / current turn too — Escape must stop the working as // well as flip the state. The abort cascade (agent_end / turn_end call // pauseActiveGoal again) is a no-op because the goal is already paused. // When the goal is already paused this branch is skipped and Escape // falls through to pi, which stops the current turn without any goal // state change. if (matchesKey(data, "escape") && core.state.goal?.status === "active" && core.state.goal.autoContinue) { core.pauseActiveGoal(ctx); return undefined; } // Ctrl+Shift+T — toggle the unified dashboard between compact and // expanded task views (the task-list overlay is merged into the // dashboard; §10). if (matchesKey(data, keybindings.toggleExpand)) { core.toggleDashboardExpanded(); return { consume: true }; } // Ctrl+Shift+A — toggle the focused goal's independent auditor. // Persisted per-goal (revision-safe), ledger event, dashboard // refresh, notification; inert with no focused goal or a complete // goal (the modal-depth guard above covers goal modals). if (matchesKey(data, "ctrl+shift+a")) { core.toggleGoalAuditor(ctx); core.goalWidgetComponentRef.current?.invalidate(); return { consume: true }; } // Navigation keys: plain arrows scroll the expanded dashboard (it is // modal while open); Ctrl+Shift+↑/↓/PgUp/PgDn/Home/End scroll the // compact task list — free chords, consumed only when the compact // list overflows (§9.6). if (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "pageUp") || matchesKey(data, "pageDown") || matchesKey(data, "home") || matchesKey(data, "end")) { const key = matchesKey(data, "up") ? "up" : matchesKey(data, "down") ? "down" : matchesKey(data, "pageUp") ? "pageUp" : matchesKey(data, "pageDown") ? "pageDown" : matchesKey(data, "home") ? "home" : "end"; if (core.goalWidgetComponentRef?.current?.handleNavigationKey(key)) { return { consume: true }; } } if (matchesKey(data, keybindings.scrollUp) || matchesKey(data, keybindings.scrollDown) || matchesKey(data, "ctrl+shift+pageUp") || matchesKey(data, "ctrl+shift+pageDown") || matchesKey(data, "ctrl+shift+home") || matchesKey(data, "ctrl+shift+end")) { const key = matchesKey(data, keybindings.scrollUp) ? "up" : matchesKey(data, keybindings.scrollDown) ? "down" : matchesKey(data, "ctrl+shift+pageUp") ? "pageUp" : matchesKey(data, "ctrl+shift+pageDown") ? "pageDown" : matchesKey(data, "ctrl+shift+home") ? "home" : "end"; if (core.goalWidgetComponentRef?.current?.handleCompactScrollKey(key)) { return { consume: true }; } } // Debug keybindings are inert unless PI_GOAL_DEBUG is set (P1-13). if (!isDebugEnabled()) return undefined; // ── Debug mode keybindings (hidden from normal view) ──────────────── // Ctrl+Shift+X — toggle debug mode on/off if (matchesKey(data, "ctrl+shift+x")) { core.debugMode = !core.debugMode; ctx.ui.notify(core.debugMode ? "Debug mode ON" : "Debug mode OFF", "info"); core.goalWidgetComponentRef.current?.invalidate(); return { consume: true }; } // Only process the following debug keybindings when debug mode is active if (!core.debugMode) return undefined; // Ctrl+Shift+N — create a test goal if (matchesKey(data, "ctrl+shift+n")) { createDebugGoal(ctx); return { consume: true }; } // Ctrl+Shift+T — inject sample tasks into current goal if (matchesKey(data, "ctrl+shift+t")) { injectDebugTasks(ctx); return { consume: true }; } // Ctrl+Shift+R — start mock completion audit if (matchesKey(data, "ctrl+shift+r")) { startMockAudit(ctx); return { consume: true }; } // Ctrl+Shift+O — open proposal dialog with sample data if (matchesKey(data, "ctrl+shift+o")) { openDebugProposal(ctx); return { consume: true }; } return undefined; }); /** Toggle a test goal: create (first press) or remove (second press) */ function createDebugGoal(ctx: ExtensionContext): void { if (!isDebugEnabled()) return; const prev = core.state.goal; if (prev && prev.id.startsWith("debug-")) { // Toggle off — remove debug goal entirely (no archive, full delete) const filePath = `${DEBUG_GOALS_DIR}/debug_goal.md`; core.goalService.removeDebugFile(ctx, filePath); const prevId = prev.id; core.state.goal = null; if (core.focusedGoalId === prevId) { core.goalsById.delete(prevId); core.assignFocusedGoalId(null); } core.clearStoppedRuntimeState(); core.updateUI(ctx); ctx.ui.notify("Debug goal removed", "info"); return; } // Toggle on — create a new debug goal, write to temp dir debugGoalCounter++; const goal = createGoal({ objective: "=== Goal ===\nObjective: Debug test goal", autoContinue: true, sisyphus: false, }); goal.id = `debug-${nowIso().replace(/[:.]/g, "-")}-${debugGoalCounter}`; goal.createdAt = nowIso(); goal.updatedAt = nowIso(); goal.activePath = `${DEBUG_GOALS_DIR}/debug_goal.md`; core.goalService.writeDebugFile(ctx, goal.activePath, serializeGoalFile(goal)); core.setGoal(goal, ctx, false, "created"); // no persist (we already wrote the file) ctx.ui.notify(`Debug goal created: ${goal.id}`, "info"); } /** Inject 3-4 sample tasks into the current goal */ function injectDebugTasks(ctx: ExtensionContext): void { if (!isDebugEnabled()) return; if (!core.state.goal) { ctx.ui.notify("No goal to inject tasks into; create one first (Ctrl+Shift+N)", "warning"); return; } const now = nowIso(); const tasks: GoalTask[] = [ { id: "t1", title: "Set up project structure", status: "complete", completedAt: now, subtasks: [ { id: "t1a", title: "Initialize repo", status: "complete", completedAt: now }, { id: "t1b", title: "Add build config", status: "pending" }, ], }, { id: "t2", title: "Implement core feature", status: "pending", }, { id: "t3", title: "Write tests", status: "pending", }, ]; const next = cloneGoal(core.state.goal); next.taskList = { tasks, blockCompletion: false, proposedAt: now }; next.updatedAt = now; core.setGoal(next, ctx); ctx.ui.notify("Sample tasks injected (3 tasks, 1 completed)", "info"); } /** Stop mock audit timer if running */ function stopMockAuditTimer(): void { if (debugMockAuditTimer) { clearInterval(debugMockAuditTimer); debugMockAuditTimer = null; } } /** Start a mock completion audit that transitions through phases */ // ctx is part of the debug keybinding call site; the mock audit only // drives widget progress state. function startMockAudit(_ctx: ExtensionContext): void { if (!isDebugEnabled()) return; stopMockAuditTimer(); const startedAt = Date.now(); const phases: { phase: AuditorWidgetProgress["phase"]; atMs: number; label: string; percentage: number }[] = [ { phase: "tool_executing", atMs: 0, label: "Checking test results...", percentage: 10 }, { phase: "tool_executing", atMs: 800, label: "Verifying requirements...", percentage: 30 }, { phase: "thinking", atMs: 1800, label: "Evaluating completion criteria...", percentage: 60 }, { phase: "producing_report", atMs: 3200, label: "Writing audit report...", percentage: 85 }, { phase: "done", atMs: 4800, label: "Audit complete", percentage: 100 }, ]; core.auditProgress = { recentOutput: [], phase: "running", elapsedMs: 0, }; core.goalWidgetComponentRef.current?.invalidate(); debugMockAuditTimer = setInterval(() => { const elapsed = Date.now() - startedAt; let currentPhase: AuditorWidgetProgress["phase"] = "done"; let currentLabel = "Audit complete"; let currentPct = 100; for (let i = phases.length - 1; i >= 0; i--) { if (elapsed >= phases[i]!.atMs) { currentPhase = phases[i]!.phase; currentLabel = phases[i]!.label; currentPct = phases[i]!.percentage; break; } } core.auditProgress = { phase: currentPhase, label: currentLabel, percentage: currentPct, elapsedMs: elapsed, recentOutput: core.auditProgress?.recentOutput ?? [], }; if (currentPhase === "done") { if (core.auditProgress) core.auditProgress.recentOutput = [ "✓ All requirements verified", "✓ Tests pass: 310/310", "✓ No truncation cap remaining", ]; stopMockAuditTimer(); // Auto-clear audit after 3 more seconds setTimeout(() => { core.auditProgress = null; core.goalWidgetComponentRef.current?.invalidate(); }, 3000); } core.goalWidgetComponentRef.current?.invalidate(); }, 100); debugMockAuditTimer.unref?.(); } /** Show the proposal dialog using real goal state — no hardcoded text */ function openDebugProposal(ctx: ExtensionContext): void { if (!isDebugEnabled()) return; // Build a fresh debug goal + tasks in memory for the dialog debugGoalCounter++; const goal = createGoal({ objective: `=== Goal === Objective: Add collapsible task sections to the goal widget so large task lists are navigable Success criteria: - Tasks are grouped into sections by status (pending, active, complete) with visible section headers - Each section header is toggleable — clicking it expands or collapses that section - When collapsed, the section shows a header line only with a task count badge - When expanded, tasks render with normal indentation and per-line styling - Default state: pending section expanded, active and complete sections collapsed - Section state is tracked per-render (no persistence needed) - All 310 existing tests still pass Boundaries: - In scope: GoalWidgetComponent.render() grouping logic, section header toggling, expand/collapse state per render cycle - Out of scope: task reordering, drag-and-drop, keyboard navigation for sections, persistence of section state across pi restarts - Out of scope: modifying GoalTask or GoalRecord types Constraints: - Render width must respect the existing width parameter — no hardcoded widths - Section collapse state is a render-only map, not stored in goal record - Collapse toggle must be keyboard-accessible via existing widget interaction model - Do not change the GoalWidgetComponent public API (constructor options, render signature) - Section headers must use theme.fg("accent", ...) consistent with existing render patterns Verification contract: - Run npm test and confirm 310/310 pass (0 failures) - Read render method and confirm task grouping logic exists - Read expand/collapse toggle handler and confirm it inverts section state - Confirm collapsed sections only render the header line with task count - Confirm expanded sections render tasks with correct indentation and styling`, autoContinue: true, sisyphus: false, }); goal.id = `debug-${nowIso().replace(/[:.]/g, "-")}-${debugGoalCounter}`; goal.createdAt = nowIso(); goal.updatedAt = nowIso(); const now = nowIso(); const tasks: GoalTask[] = [ { id: "t1", title: "Set up project structure", status: "complete", completedAt: now, subtasks: [ { id: "t1a", title: "Initialize repo", status: "complete", completedAt: now }, { id: "t1b", title: "Add build config", status: "pending" }, ], }, { id: "t2", title: "Implement core feature", status: "pending", subtasks: [ { id: "t2a", title: "Status grouping logic", status: "pending" }, { id: "t2b", title: "Section header component", status: "pending" }, { id: "t2c", title: "Expand/collapse state", status: "pending" }, { id: "t2d", title: "Task count badge", status: "pending" }, ], }, { id: "t3", title: "Update tests", status: "pending" }, { id: "t4", title: "Manual TUI verification", status: "pending" }, ]; goal.taskList = { tasks, blockCompletion: false, proposedAt: now }; // Development-only diagnostic: the legacy proposal dialog is // gone; the injected sample task list is inspected through the goal // file and the debug task overlay (Ctrl+Shift+T). ctx.ui.notify( `Injected sample task list into debug goal ${goal.id}; inspect the goal file or use the debug task overlay.`, "info", ); } }