diff --git a/README.md b/README.md index 72206d0..2330164 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,10 @@ After compaction, the model calls `planner_status`, reloads from persisted JSON/ | Command | Purpose | | --- | --- | -| `/planner-create` | Create a new plan from a multiline request. | -| `/planner-improve` | Discovery-first self-improvement plan. | -| `/planner-preview` | Check out the plan branch in your main repo to browse accumulated files. Run again for status. `/planner-finish` restores your branch automatically. | -| `/planner-resume` | Pick a plan and resume its worktree session. | +| `/planner-create` | Create a new plan from a multiline request. Opens the planner workspace. | +| `/planner-improve` | Discovery-first self-improvement plan. Opens the planner workspace. | +| `/planner-resume` | Pick a plan and resume its worktree session. Opens the planner workspace. | +| `/planner-dashboard` | Open the planner workspace: live stage dashboard, task list, and the model chat in one window. Opens automatically for planner-worktree sessions. | | `/planner-helper` | Show current effective settings and planner behavior. | | `/planner-skills` | Search, view, and delete planner-generated skills. | | `/planner-finish` | Export `output/`, remove temporary planner state, return Pi to the original session. | diff --git a/SETTINGS.md b/SETTINGS.md index c2ac9fb..1952889 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -49,6 +49,11 @@ getAgentDir()/extensions/pi-code-planner/settings.json }, "requireAfterTdd": true, "requireBeforeEditOutsideChain": true + }, + "workspace": { + "enabled": true, + "autoOpen": true, + "footerReserveRows": 3 } } ``` @@ -126,6 +131,48 @@ Planner-generated skills are stored under `getAgentDir()/extensions/pi-code-plan | `contracts.requireAfterTdd` | `true` | Require `execution/contract_check` after a green implementation. | | `contracts.requireBeforeEditOutsideChain` | `true` | Instruct the model to route/read contracts before leaving declared task scope. | +## Workspace (TUI) + +`/planner-dashboard` opens the planner workspace: the stage dashboard and the model chat in one window. It also opens automatically for planner-worktree sessions (after `/planner-create`, `/planner-resume`, `/planner-improve`). + +| Setting | Default | Purpose | +| --- | --- | --- | +| `workspace.enabled` | `true` | Master switch for the workspace window. | +| `workspace.autoOpen` | `true` | Open the workspace automatically for planner-worktree sessions. | +| `workspace.footerReserveRows` | `3` | Terminal rows left for Pi's native footer below the workspace overlay. Raise if the footer overlaps; lower if there is a gap. `0`–`20`. | + +### Workspace keys + +Inside the workspace, `Tab` cycles three focus panes: + +| Pane | Keys | +| --- | --- | +| input | type or paste, `Enter` to send to the model | +| chat | `↑`/`↓`, `PageUp`/`PageDown` scroll; `End` jumps back to the live tail, `Home` to the top; `x` toggles expand-all for collapsed tool calls | +| tasks | `↑`/`↓` select a task and reveal the task list + stage timings; `←`/`→` nudge the ticker | + +While scrolled up, the transcript stays anchored — new streamed output appends below without moving your view. Press `End` to jump back to the live tail. History is projected as a sliding window over the session (a chunk of trailing entries); scrolling to the top loads the next older chunk, so very long sessions never project the whole conversation at once. + +Pasting text into the input works (bracketed paste is handled; newlines fold to spaces). Pasting **images** is not supported in the workspace window — Pi's image paste targets its built-in editor, which the workspace replaces; close the workspace (`Esc`) to use the plain editor for image input. + +The workspace also inherits two Pi bindings (work in any pane): `app.thinking.toggle` (default `Ctrl+T`) shows/hides thinking blocks, and `app.tools.expand` (default `Ctrl+O`) expands/collapses tool output. Rebind them in `~/.pi/agent/keybindings.json`. + +The workspace's own keys are configurable in planner settings (Pi's `keybindings.json` only accepts Pi's built-in action ids, not ours). Override any of them under `workspace.keys`; omitted actions keep their defaults: + +```json +{ "workspace": { "keys": { "jumpBottom": ["end", "ctrl+e"], "expand": ["x", "o"] } } } +``` + +Actions: `focusNext` (`tab`), `up` (`up`), `down` (`down`), `pageUp` (`pageUp`), `pageDown` (`pageDown`), `jumpBottom` (`end`), `jumpTop` (`home`), `expand` (`x`), `submit` (`enter`), `exit` (`escape`). `Ctrl+C` always exits regardless of overrides. + +The line under the stage ribbon is a static context line (active task, branch, or a blocking note), clipped with `…` — it does not scroll, so it never forces a repaint. + +`Esc` (or `Ctrl+C`) closes the workspace and returns to the plain chat. Streaming assistant output appears live, token by token. + +### Pi keybindings + +The workspace keys above are handled by the extension. Pi's own shortcuts (cursor movement, model/thinking selectors, tool expansion, etc.) are configured globally in `~/.pi/agent/keybindings.json` using namespaced ids such as `tui.editor.cursorUp` and `app.tools.expand`. Each id maps to one key or an array of keys; run `/reload` after editing. See the Pi keybindings documentation for the full list. + ## Instruction Append Files Place extra instructions (test commands, architecture notes, commit style) under: diff --git a/package-lock.json b/package-lock.json index 8b9bd74..dbf4f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,14 @@ "devDependencies": { "@biomejs/biome": "^2.4.15", "@earendil-works/pi-coding-agent": "0.75.4", + "@earendil-works/pi-tui": "0.75.4", "@types/node": "^24.0.0", "typescript": "^6.0.3", "vitest": "^4.1.6" }, "peerDependencies": { - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" } }, "node_modules/@biomejs/biome": { @@ -2130,6 +2132,23 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", + "integrity": "sha512-PDhKU7u6fmEcvHUFHzrRwGc/Ytokj/hO+X4RPf+MWKEGpvg3B1vHv88Ee+Dy33004tYkQF5YeXV4btJZcp5x1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "koffi": "2.16.2" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2745,6 +2764,31 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/koffi": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", + "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3028,6 +3072,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", diff --git a/package.json b/package.json index 79d322f..b55f90a 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "test:watch": "vitest" }, "peerDependencies": { - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.75.4", + "@earendil-works/pi-tui": "0.75.4", "@biomejs/biome": "^2.4.15", "@types/node": "^24.0.0", "typescript": "^6.0.3", diff --git a/src/index.ts b/src/index.ts index 9a43f4b..1009def 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,10 @@ import { PLANNER_CONTRACT_TOOL_NAMES, type PlannerContractToolName, } from "./runtime/contracts"; +import { + openPlannerWorkspace, + registerPlannerDashboard, +} from "./runtime/dashboard"; import { DEBUG_INSTRUMENTATION_TYPES, DEBUG_PROBE_METHODS, @@ -1111,6 +1115,8 @@ export default function piCodePlannerExtension(pi: ExtensionAPI): void { registerPlannerTools(pi, compactRuntime); registerPlannerIdleWatchdog(pi, idleRuntime); registerPlannerRuntimeTimer(pi, timerRuntime); + registerPlannerDashboard(pi); + registerPlannerWorkspaceAutoOpen(pi); registerPlannerBuiltinToolGuard(pi); registerPlannerCompactEvents(pi, compactRuntime); registerPlannerSkillResources(pi); @@ -1118,6 +1124,38 @@ export default function piCodePlannerExtension(pi: ExtensionAPI): void { registerPlannerToolVisibility(pi); } +/** + * Auto-open the planner workspace once per session when the active session is a + * planner worktree session. This covers /planner-create, /planner-resume, and + * /planner-improve (each hands off to a worktree session) without coupling to + * the fragile session-switch flow. The user can reopen anytime with + * /planner-dashboard, or close the workspace to fall back to the plain chat. + */ +function registerPlannerWorkspaceAutoOpen(pi: ExtensionAPI): void { + const openedSessions = new Set(); + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + try { + const sessionId = ctx.sessionManager.getSessionId(); + if (openedSessions.has(sessionId)) return; + const fs = createNodeFs(); + const projectPaths = await resolveProjectStoragePaths({ + fs, + agentDir: getAgentDir(), + cwd: ctx.cwd, + }); + const context = await readActivePlanContext({ fs, projectPaths }); + if (context.status !== "ready") return; + const worktreePath = context.state.worktreePath; + if (!worktreePath || !isPathInsideOrEqual(ctx.cwd, worktreePath)) return; + openedSessions.add(sessionId); + void openPlannerWorkspace(pi, ctx, { auto: true }); + } catch { + // Best-effort: never block session start on the workspace. + } + }); +} + function registerPlannerSkillResources(pi: ExtensionAPI): void { pi.on("resources_discover", async (event) => { const fs = createNodeFs(); @@ -1936,57 +1974,6 @@ function registerPlannerCommands(pi: ExtensionAPI): void { }, }); - pi.registerCommand("planner-preview", { - description: - "Show the planner worktree path so you can open it in your editor and browse the current plan files directly.", - handler: async (_args, ctx) => { - await ctx.waitForIdle(); - const fs = createNodeFs(); - const git = new NodeGitRunner(); - try { - const agentDir = getAgentDir(); - const projectPaths = await resolveProjectStoragePaths({ - fs, - agentDir, - cwd: ctx.cwd, - }); - const project = await readProjectRecordIfExists(fs, projectPaths); - const activePlanId = project?.activePlanId; - if (!activePlanId) { - ctx.ui.notify("No active plan to preview.", "info"); - return; - } - const planPaths = createPlanStoragePaths(projectPaths, activePlanId); - const state = await readPlanStateIfExists(fs, planPaths); - if (!state?.worktreePath) { - ctx.ui.notify("Plan worktree not found.", "warning"); - return; - } - const worktreePath = state.worktreePath; - const currentBranch = await git - .currentBranch({ repoRoot: worktreePath }) - .catch(() => state.currentBranch ?? "(unknown)"); - const planBranch = state.activeBranches.plan; - const onPlanBranch = currentBranch === planBranch; - ctx.ui.notify( - [ - `Planner worktree: ${worktreePath}`, - `Branch: ${currentBranch}`, - onPlanBranch - ? "Open this folder in your editor to browse all completed plan work." - : `Currently on a task branch. Plan branch (${planBranch}) contains all completed tasks merged so far.`, - ].join("\n"), - "info", - ); - } catch (error) { - ctx.ui.notify( - `Planner preview failed: ${errorMessage(error)}`, - "error", - ); - } - }, - }); - pi.registerCommand("planner-finish", { description: "Finish the completed planner result, keep one output branch, clean temporary planner state, and return to the original project session.", diff --git a/src/runtime/about.ts b/src/runtime/about.ts index be0e4dd..2cc9b39 100644 --- a/src/runtime/about.ts +++ b/src/runtime/about.ts @@ -117,6 +117,21 @@ const SETTING_DESCRIPTORS: SettingDescriptor[] = [ purpose: "Instruct the model to route/read contracts before leaving declared task scope.", }, + { + path: "workspace.enabled", + purpose: + "Master switch for the /planner-dashboard workspace window (dashboard + model chat).", + }, + { + path: "workspace.autoOpen", + purpose: + "Open the workspace automatically for planner-worktree sessions (create/resume/improve).", + }, + { + path: "workspace.footerReserveRows", + purpose: + "Terminal rows left for Pi's native footer below the workspace overlay (raise if the footer overlaps).", + }, { path: "metadata.humanLanguage", purpose: "Default language for user-facing planner text.", @@ -181,6 +196,18 @@ export function buildPlannerAboutReport(input: { "- /planner-skills shows the saved inventory even when runtime exposure is disabled or capped.", "- Missing SKILL.md files are ignored by inventory/resource discovery; delete stale index entries through /planner-skills when needed.", "", + "## Planner Workspace TUI", + "- /planner-dashboard opens the workspace: stage dashboard + the model chat in one window. It also opens automatically for planner-worktree sessions (workspace.autoOpen).", + "- Inside the workspace, Tab cycles three focus panes:", + " - input: type or paste, press Enter to send a message to the model.", + " - chat: ↑↓ / PageUp / PageDown scroll; End jumps to the live tail, Home to the top; x toggles expand-all for tool calls.", + " - tasks: ↑↓ select a task and reveal the task list + stage timings; ←→ nudge the ticker.", + "- Inherits Pi bindings: app.thinking.toggle (Ctrl+T) hides/shows thinking; app.tools.expand (Ctrl+O) expands/collapses tool output.", + "- Esc (or Ctrl+C) closes the workspace and returns to the plain chat.", + "- Streaming assistant output is shown live, token by token, as it is generated.", + "- Pi's own keys (cursor movement, model/thinking selectors, etc.) are configured in ~/.pi/agent/keybindings.json; run /reload after editing. See SETTINGS.md and the Pi keybindings docs.", + "- If Pi's native footer overlaps or leaves a gap below the workspace, tune workspace.footerReserveRows.", + "", "## Effective Settings", "Settings merge order: defaults, global settings, then project settings.", "", @@ -192,7 +219,7 @@ export function buildPlannerAboutReport(input: { "", "## Notes", "- worktree and compact settings are captured when a plan is created.", - "- idle, timer, metadata, skills, and contracts settings are read while planner runs.", + "- idle, timer, metadata, skills, contracts, and workspace settings are read while planner runs.", "- skills.maxActive = 0 means no planner-side limit.", ].join("\n"); } diff --git a/src/runtime/chat-view.test.ts b/src/runtime/chat-view.test.ts new file mode 100644 index 0000000..51f5af1 --- /dev/null +++ b/src/runtime/chat-view.test.ts @@ -0,0 +1,217 @@ +import type { SessionEntry } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { + type ChatRow, + projectSessionEntries, + renderTranscript, + type TranscriptOptions, +} from "./chat-view"; +import type { DashboardPalette } from "./dashboard-model"; + +const palette: DashboardPalette = { + text: (s) => s, + accent: (s) => s, + muted: (s) => s, + dim: (s) => s, + success: (s) => s, + warning: (s) => s, + error: (s) => s, + border: (s) => s, + bold: (s) => s, + inverse: (s) => s, + stage: (_stage, s) => s, + measure: (s) => s.length, + clip: (s, width) => (s.length <= width ? s : s.slice(0, Math.max(0, width))), +}; + +function messageEntry(id: string, message: unknown): SessionEntry { + return { + type: "message", + id, + parentId: null, + timestamp: "2026-06-15T00:00:00.000Z", + message, + } as SessionEntry; +} + +function baseOptions(over: Partial = {}): TranscriptOptions { + return { + width: 60, + height: 10, + atBottom: true, + topLine: 0, + expanded: new Set(), + ...over, + }; +} + +describe("projectSessionEntries", () => { + it("projects user, assistant text, tool calls, and tool results", () => { + const entries: SessionEntry[] = [ + messageEntry("u1", { role: "user", content: "hello model" }), + messageEntry("a1", { + role: "assistant", + content: [ + { type: "thinking", thinking: "let me think" }, + { type: "text", text: "Sure, doing it." }, + { + type: "toolCall", + name: "planner_status", + arguments: { verbose: true }, + }, + ], + }), + messageEntry("r1", { + role: "toolResult", + toolName: "planner_status", + isError: false, + content: [{ type: "text", text: "stage: execution" }], + }), + ]; + + const rows = projectSessionEntries(entries); + const roles = rows.map((r) => r.role); + expect(roles).toContain("user"); + expect(roles).toContain("thinking"); + expect(roles).toContain("assistant"); + expect(roles).toContain("tool"); + expect(roles).toContain("tool_result"); + + const tool = rows.find((r) => r.role === "tool") as ChatRow; + expect(tool.label).toBe("planner_status"); + expect(tool.text).toContain("verbose=true"); + + const result = rows.find((r) => r.role === "tool_result") as ChatRow; + expect(result.isError).toBe(false); + }); + + it("marks tool result errors and skips hidden custom messages", () => { + const entries: SessionEntry[] = [ + messageEntry("r1", { + role: "toolResult", + toolName: "planner_git_commit", + isError: true, + content: [{ type: "text", text: "blocked" }], + }), + { + type: "custom_message", + id: "c1", + parentId: null, + timestamp: "2026-06-15T00:00:00.000Z", + customType: "planner-internal", + content: "hidden", + display: false, + } as SessionEntry, + ]; + const rows = projectSessionEntries(entries); + expect(rows).toHaveLength(1); + expect(rows[0].isError).toBe(true); + }); +}); + +describe("renderTranscript", () => { + const rows: ChatRow[] = [ + { role: "user", text: "first question", collapsible: false, key: "u1" }, + { + role: "assistant", + text: "a long answer ".repeat(10).trim(), + collapsible: false, + key: "a1", + }, + { + role: "tool", + label: "planner_status", + text: "verbose=true extra=1", + collapsible: true, + key: "t1", + }, + ]; + + it("returns exactly height lines bounded to width", () => { + const result = renderTranscript(rows, baseOptions(), palette); + expect(result.lines).toHaveLength(10); + for (const line of result.lines) { + expect(line.length).toBe(60); + } + }); + + it("collapses tool rows to a single line by default and expands on demand", () => { + const longTool: ChatRow[] = [ + { + role: "tool", + label: "planner_status", + text: "line one\nline two\nline three", + collapsible: true, + key: "t1", + }, + ]; + const collapsed = renderTranscript(longTool, baseOptions(), palette); + const collapsedText = collapsed.lines.join("\n"); + expect(collapsedText).toContain("…"); + + const expanded = renderTranscript( + longTool, + baseOptions({ expanded: new Set(["t1"]) }), + palette, + ); + const expandedText = expanded.lines.join("\n"); + expect(expandedText).toContain("line two"); + expect(expandedText).toContain("line three"); + }); + + it("expands tabs and strips control bytes so the frame stays aligned", () => { + const tabbed: ChatRow[] = [ + { + role: "assistant", + text: "\t\tconst x = 1;\u001b[31mred\u001b[0m", + collapsible: false, + key: "a1", + }, + ]; + const result = renderTranscript( + tabbed, + baseOptions({ width: 40 }), + palette, + ); + const joined = result.lines.join("\n"); + expect(joined).not.toContain("\t"); + expect(joined).not.toContain("\u001b"); + expect(joined).toContain("const x = 1;red"); + for (const line of result.lines) { + expect(line.length).toBe(40); + } + }); + + it("shows an empty-state hint when there are no rows", () => { + const result = renderTranscript([], baseOptions(), palette); + expect(result.lines.join("\n")).toContain("No conversation yet"); + }); + + it("anchors to an absolute top line and stays put as content appends", () => { + const make = (n: number): ChatRow[] => + Array.from({ length: n }, (_, i) => ({ + role: "user" as const, + text: `message ${i}`, + collapsible: false, + key: `m${i}`, + })); + + const bottom = renderTranscript(make(40), baseOptions(), palette); + expect(bottom.totalLines).toBe(40); + expect(bottom.topLine).toBe(30); + + // Anchored at top line 5; appending more rows must NOT move the view. + const before = renderTranscript( + make(40), + baseOptions({ atBottom: false, topLine: 5 }), + palette, + ); + const after = renderTranscript( + make(60), + baseOptions({ atBottom: false, topLine: 5 }), + palette, + ); + expect(after.topLine).toBe(5); + expect(after.lines.join("\n")).toBe(before.lines.join("\n")); + }); +}); diff --git a/src/runtime/chat-view.ts b/src/runtime/chat-view.ts new file mode 100644 index 0000000..74ea55f --- /dev/null +++ b/src/runtime/chat-view.ts @@ -0,0 +1,441 @@ +import type { SessionEntry } from "@earendil-works/pi-coding-agent"; +import type { DashboardPalette } from "./dashboard-model"; + +/** + * Pure transcript projection + rendering for the planner workspace chat pane. + * + * `projectSessionEntries` turns Pi session entries into a flat list of display + * rows. `renderTranscript` renders those rows into bounded lines using an + * injected palette, so the whole module is unit-testable with an identity + * palette and plain data. + */ + +export type ChatRole = + | "user" + | "assistant" + | "thinking" + | "tool" + | "tool_result" + | "event"; + +export interface ChatRow { + role: ChatRole; + /** Primary text (plain; may contain newlines). */ + text: string; + /** Short label shown before the text (tool name, status, etc.). */ + label?: string; + /** Whether this row can be expanded to show full content. */ + collapsible: boolean; + isError?: boolean; + /** Stable key for expand/collapse tracking. */ + key: string; +} + +const MAX_COLLAPSED_LINES = 1; +const MAX_EXPANDED_LINES = 40; + +export function projectSessionEntries(entries: SessionEntry[]): ChatRow[] { + const rows: ChatRow[] = []; + for (const entry of entries) { + if (entry.type === "compaction") { + rows.push({ + role: "event", + label: "compacted", + text: entry.summary || "(context compacted)", + collapsible: true, + key: entry.id, + }); + continue; + } + if (entry.type === "custom_message" && entry.display) { + rows.push({ + role: "event", + label: entry.customType, + text: contentToText(entry.content), + collapsible: true, + key: entry.id, + }); + continue; + } + if (entry.type !== "message") continue; + rows.push(...projectMessage(entry.id, entry.message)); + } + return rows; +} + +/** + * Project an in-flight (streaming) assistant message into transcript rows. + * Keys are namespaced so they never collide with committed session entries. + */ +export function projectLiveAssistant(message: unknown): ChatRow[] { + return projectMessage("live", message).map((row) => ({ + ...row, + key: `live:${row.key}`, + })); +} + +function projectMessage(id: string, message: unknown): ChatRow[] { + const role = readRole(message); + if (role === "user") { + return [ + { + role: "user", + text: contentToText(readContent(message)), + collapsible: false, + key: id, + }, + ]; + } + if (role === "toolResult") { + const toolName = readString(message, "toolName") ?? "tool"; + const isError = readBoolean(message, "isError"); + return [ + { + role: "tool_result", + label: `${toolName} ${isError ? "error" : "ok"}`, + text: contentToText(readContent(message)), + collapsible: true, + isError, + key: id, + }, + ]; + } + if (role === "assistant") { + return projectAssistantBlocks(id, readBlocks(message)); + } + // Custom / unknown roles: show a compact event line. + return [ + { + role: "event", + label: String(role), + text: contentToText(readContent(message)), + collapsible: true, + key: id, + }, + ]; +} + +function projectAssistantBlocks(id: string, blocks: unknown[]): ChatRow[] { + const rows: ChatRow[] = []; + let textIndex = 0; + let blockIndex = 0; + for (const block of blocks) { + const type = readString(block, "type"); + if (type === "text") { + const text = readString(block, "text")?.trim(); + if (text) { + rows.push({ + role: "assistant", + text, + collapsible: false, + key: `${id}:t${textIndex++}`, + }); + } + } else if (type === "thinking") { + const thinking = readString(block, "thinking")?.trim(); + if (thinking) { + rows.push({ + role: "thinking", + label: "thinking", + text: thinking, + collapsible: true, + key: `${id}:think${blockIndex}`, + }); + } + } else if (type === "toolCall") { + const name = readString(block, "name") ?? "tool"; + rows.push({ + role: "tool", + label: name, + text: argsToText(readRecord(block, "arguments")), + collapsible: true, + key: `${id}:tool${blockIndex}`, + }); + } + blockIndex++; + } + return rows; +} + +export interface TranscriptOptions { + width: number; + height: number; + /** Follow the newest output (pinned to the live tail). */ + atBottom: boolean; + /** + * Absolute index of the first visible line when not following the tail. + * Anchoring from the top keeps the view fixed while new lines append below. + */ + topLine: number; + expanded: ReadonlySet; +} + +export interface TranscriptResult { + lines: string[]; + /** Total renderable lines (for scroll clamping by the caller). */ + totalLines: number; + /** Resolved (clamped) first visible line index. */ + topLine: number; +} + +export function renderTranscript( + rows: ChatRow[], + options: TranscriptOptions, + palette: DashboardPalette, +): TranscriptResult { + const width = Math.max(8, options.width); + const height = Math.max(1, options.height); + const all: string[] = []; + if (rows.length === 0) { + all.push( + palette.dim("No conversation yet. Type below to talk to the model."), + ); + } + for (const row of rows) { + for (const line of renderRow(row, width, options.expanded, palette)) { + all.push(line); + } + } + + const total = all.length; + const maxTop = Math.max(0, total - height); + const start = options.atBottom + ? maxTop + : Math.min(Math.max(0, options.topLine), maxTop); + const window = all.slice(start, start + height); + while (window.length < height) window.push(""); + return { + lines: window.map((line) => clipPad(line, width, palette)), + totalLines: total, + topLine: start, + }; +} + +function renderRow( + row: ChatRow, + width: number, + expanded: ReadonlySet, + palette: DashboardPalette, +): string[] { + const isExpanded = expanded.has(row.key); + const marker = row.collapsible + ? isExpanded + ? palette.dim("▾ ") + : palette.dim("▸ ") + : " "; + const head = rowHead(row, palette); + const bodyWidth = width; + const textLines = splitText(row.text); + const limit = row.collapsible + ? isExpanded + ? MAX_EXPANDED_LINES + : MAX_COLLAPSED_LINES + : MAX_EXPANDED_LINES; + + const fullWrapped: string[] = []; + for (const raw of textLines) { + for (const piece of wrapPlain(raw, Math.max(4, bodyWidth - 2))) { + fullWrapped.push(piece); + } + } + const wrapped = fullWrapped.slice(0, limit); + const truncated = + row.collapsible && !isExpanded && fullWrapped.length > wrapped.length; + + const out: string[] = []; + const colorBody = bodyColor(row, palette); + if (head) { + const firstBody = wrapped.shift() ?? ""; + const suffix = truncated ? palette.dim(" …") : ""; + out.push(`${marker}${head} ${colorBody(firstBody)}${suffix}`); + } + for (const line of wrapped) { + out.push(` ${colorBody(line)}`); + } + if (!head && out.length === 0) out.push(marker); + return out; +} + +function rowHead(row: ChatRow, palette: DashboardPalette): string { + switch (row.role) { + case "user": + return palette.accent("› you"); + case "assistant": + return ""; + case "thinking": + return palette.dim("✻ thinking"); + case "tool": + return palette.warning(`⚙ ${row.label ?? "tool"}`); + case "tool_result": + return row.isError + ? palette.error(`↳ ${row.label ?? "result"}`) + : palette.success(`↳ ${row.label ?? "result"}`); + case "event": + return palette.dim(`• ${row.label ?? "event"}`); + } +} + +function bodyColor( + row: ChatRow, + palette: DashboardPalette, +): (s: string) => string { + switch (row.role) { + case "user": + return (s) => palette.text(s); + case "assistant": + return (s) => palette.text(s); + case "thinking": + return (s) => palette.dim(s); + case "tool": + return (s) => palette.muted(s); + case "tool_result": + return row.isError ? (s) => palette.error(s) : (s) => palette.muted(s); + case "event": + return (s) => palette.dim(s); + } +} + +// --------------------------------------------------------------------------- +// Text helpers +// --------------------------------------------------------------------------- + +function splitText(text: string): string[] { + return sanitizeText(text).split("\n"); +} + +/** + * Make arbitrary message/tool text safe to lay out by fixed-width math: + * expand tabs (terminals render them 1..8 cols wide but width math counts 1), + * strip ANSI escapes and other control chars that would desync the frame. + */ +function sanitizeText(text: string): string { + // Built from char codes so the source stays ASCII-only. + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-byte stripping + const ansi = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-byte stripping + const control = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; + return text + .replace(/\r/g, "") + .replace(/\t/g, " ") + .replace(ansi, "") + .replace(control, ""); +} + +function wrapPlain(text: string, width: number): string[] { + if (text.length === 0) return [""]; + const words = text.split(/(\s+)/); + const lines: string[] = []; + let current = ""; + for (const word of words) { + if (word.length > width) { + if (current) { + lines.push(current); + current = ""; + } + for (let i = 0; i < word.length; i += width) { + const chunk = word.slice(i, i + width); + if (chunk.length === width) lines.push(chunk); + else current = chunk; + } + continue; + } + if ((current + word).length > width) { + lines.push(current.trimEnd()); + current = word.trimStart(); + } else { + current += word; + } + } + if (current.trim().length > 0 || lines.length === 0) + lines.push(current.trimEnd()); + return lines; +} + +function clipPad( + value: string, + width: number, + palette: DashboardPalette, +): string { + const clipped = palette.clip(value, width); + const pad = Math.max(0, width - palette.measure(clipped)); + return clipped + " ".repeat(pad); +} + +function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((block) => { + if (typeof block === "string") return block; + const type = readString(block, "type"); + if (type === "text") return readString(block, "text") ?? ""; + if (type === "image") return "[image]"; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +function argsToText(args: Record | null): string { + if (!args) return ""; + const keys = Object.keys(args); + if (keys.length === 0) return ""; + return keys.map((key) => `${key}=${stringifyArg(args[key])}`).join(" "); +} + +function stringifyArg(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +// --------------------------------------------------------------------------- +// Defensive readers for loosely-typed message blocks +// --------------------------------------------------------------------------- + +function readRole(message: unknown): string { + return readString(message, "role") ?? "unknown"; +} + +function readContent(message: unknown): unknown { + if (message && typeof message === "object" && "content" in message) { + return (message as { content: unknown }).content; + } + return ""; +} + +function readBlocks(message: unknown): unknown[] { + const content = readContent(message); + return Array.isArray(content) ? content : []; +} + +function readString(value: unknown, key: string): string | undefined { + if (value && typeof value === "object" && key in value) { + const v = (value as Record)[key]; + return typeof v === "string" ? v : undefined; + } + return undefined; +} + +function readBoolean(value: unknown, key: string): boolean { + if (value && typeof value === "object" && key in value) { + return Boolean((value as Record)[key]); + } + return false; +} + +function readRecord( + value: unknown, + key: string, +): Record | null { + if (value && typeof value === "object" && key in value) { + const v = (value as Record)[key]; + return v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : null; + } + return null; +} diff --git a/src/runtime/dashboard-model.test.ts b/src/runtime/dashboard-model.test.ts new file mode 100644 index 0000000..9966bfc --- /dev/null +++ b/src/runtime/dashboard-model.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from "vitest"; +import { + createPlanStoragePaths, + createProjectStoragePaths, +} from "../storage/paths"; +import { + createEmptyProjectRecord, + createInitialPlanState, + createPlanRecord, + type PlannerTimerState, + type PlanStateRecord, + type PlanTaskSummary, +} from "../storage/schema"; +import type { + ActivePlanContextReady, + ActivePlanContextUnavailable, +} from "./active-plan"; +import { + applyLiveTiming, + buildPlannerDashboardModel, + composeDashboard, + DASHBOARD_STAGE_SEQUENCE, + type DashboardModel, + type DashboardPalette, + type DashboardUiState, + liveTotalMs, + renderContextLine, + renderStageRibbon, +} from "./dashboard-model"; + +const identityPalette: DashboardPalette = { + text: (s) => s, + accent: (s) => s, + muted: (s) => s, + dim: (s) => s, + success: (s) => s, + warning: (s) => s, + error: (s) => s, + border: (s) => s, + bold: (s) => s, + inverse: (s) => s, + stage: (_stage, s) => s, + measure: (s) => s.length, + clip: (s, width) => (s.length <= width ? s : s.slice(0, Math.max(0, width))), +}; + +const defaultUi: DashboardUiState = { + selectedIndex: 0, + taskScroll: 0, + focus: "tasks", +}; + +function readyContext( + overrides: Partial = {}, + options: { + tasks?: PlanTaskSummary[]; + title?: string; + timer?: PlannerTimerState; + } = {}, +): ActivePlanContextReady { + const projectPaths = createProjectStoragePaths({ + agentDir: "/agent", + projectRoot: "/repo/app", + }); + const planPaths = createPlanStoragePaths(projectPaths, "plan-a"); + const state: PlanStateRecord = { + ...createInitialPlanState({ + baseBranch: "main", + planBranch: "plan/plan-a", + worktreePath: "/repo/app/.pi/worktrees/plan-a", + }), + ...overrides, + }; + if (options.timer) state.timer = options.timer; + return { + status: "ready", + projectPaths, + planPaths, + project: { + ...createEmptyProjectRecord({ + projectId: projectPaths.projectId, + projectRoot: "/repo/app", + displayName: "app", + }), + activePlanId: "plan-a", + }, + plan: { + ...createPlanRecord({ + planId: "plan-a", + title: options.title ?? "Plan A", + }), + status: "active", + tasks: options.tasks ?? [], + }, + state, + activePlanId: "plan-a", + }; +} + +function unavailableContext( + status: ActivePlanContextUnavailable["status"], +): ActivePlanContextUnavailable { + const projectPaths = createProjectStoragePaths({ + agentDir: "/agent", + projectRoot: "/repo/app", + }); + return { + status, + projectPaths, + project: null, + activePlanId: null, + reason: "unavailable", + }; +} + +function timerWith( + checkpoints: PlannerTimerState["checkpoints"], + activeMs: number, +): PlannerTimerState { + return { + startedAt: 0, + lastSyncedAt: activeMs, + activeMs, + pausedAt: null, + finishedAt: null, + stage: checkpoints[checkpoints.length - 1]?.stage ?? "init", + stageStartedAt: 0, + checkpoints, + }; +} + +describe("buildPlannerDashboardModel", () => { + it("reports unavailable when no plan is active", () => { + const model = buildPlannerDashboardModel({ + context: unavailableContext("no_active_plan"), + now: 1000, + }); + expect(model.available).toBe(false); + if (!model.available) { + expect(model.hint).toContain("/planner-create"); + } + }); + + it("derives stage position, progress, and task counts", () => { + const tasks: PlanTaskSummary[] = [ + { taskId: "task-1", title: "Schema", status: "done" }, + { taskId: "task-2", title: "Runner", status: "done" }, + { taskId: "task-3", title: "Codec", status: "active" }, + { taskId: "task-4", title: "Policy", status: "pending" }, + ]; + const model = buildPlannerDashboardModel({ + context: readyContext( + { + stage: "execution", + step: "implement_task", + stepStatus: "running", + activeTaskId: "task-3", + currentBranch: "task/plan-a/task-3", + }, + { tasks }, + ), + now: 5000, + }) as DashboardModel; + + expect(model.available).toBe(true); + expect(model.stage).toBe("execution"); + expect(model.stageIndex).toBe( + DASHBOARD_STAGE_SEQUENCE.indexOf("execution"), + ); + expect(model.stepCount).toBeGreaterThan(0); + expect(model.tasksDone).toBe(2); + expect(model.tasksTotal).toBe(4); + expect(model.overallRatio).toBeGreaterThan(0); + expect(model.overallRatio).toBeLessThan(1); + expect(model.statusLabel).toBe("run"); + }); + + it("aggregates per-stage timings from timer checkpoints", () => { + const timer = timerWith( + [ + { stage: "discovery", enteredAt: 0, activeMs: 0 }, + { stage: "planning", enteredAt: 0, activeMs: 60_000 }, + { stage: "execution", enteredAt: 0, activeMs: 150_000 }, + ], + 200_000, + ); + const model = buildPlannerDashboardModel({ + context: readyContext( + { stage: "execution", step: "implement_task" }, + { timer }, + ), + now: 200_000, + }) as DashboardModel; + + const byStage = Object.fromEntries( + model.timings.map((t) => [t.stage, t.activeMs]), + ); + expect(byStage.discovery).toBe(60_000); + expect(byStage.planning).toBe(90_000); + expect(byStage.execution).toBe(50_000); + expect(model.routeTrail).toEqual(["discovery", "planning", "execution"]); + }); + + it("flags stuck and recovery states", () => { + const stuck = buildPlannerDashboardModel({ + context: readyContext({ + stage: "execution", + step: "implement_task", + lastStuckAttemptId: "attempt-001", + }), + now: 1000, + }) as DashboardModel; + expect(stuck.stuck).toBe(true); + expect(stuck.statusLabel).toBe("stuck"); + + const recovery = buildPlannerDashboardModel({ + context: readyContext({ stage: "recovery", step: "read_state" }), + now: 1000, + }) as DashboardModel; + expect(recovery.recovery).toBe(true); + expect(recovery.statusLabel).toBe("recovery"); + }); +}); + +describe("renderStageRibbon", () => { + it("fills the exact width and embeds stage labels", () => { + const model = buildPlannerDashboardModel({ + context: readyContext({ stage: "execution", step: "implement_task" }), + now: 1000, + }) as DashboardModel; + const [line] = renderStageRibbon(model, 70, identityPalette); + expect(line.length).toBe(70); + expect(line).toContain("INIT"); + expect(line).toContain("DONE"); + }); + + it("falls back to an active-stage indicator when too narrow", () => { + const model = buildPlannerDashboardModel({ + context: readyContext({ stage: "discovery", step: "write_questions" }), + now: 1000, + }) as DashboardModel; + const [line] = renderStageRibbon(model, 20, identityPalette); + expect(line.length).toBe(20); + expect(line).toContain("[DISCOVERY]"); + expect(line).toContain("3/7"); + }); +}); + +describe("live timing", () => { + it("advances the clock without a disk reload while running", () => { + const timer = timerWith( + [{ stage: "execution", enteredAt: 0, activeMs: 0 }], + 60_000, + ); + const model = buildPlannerDashboardModel({ + context: readyContext( + { stage: "execution", step: "implement_task" }, + { timer }, + ), + now: 60_000, + syncMs: 600_000, + }) as DashboardModel; + + expect(liveTotalMs(model, 60_000)).toBe(60_000); + expect(liveTotalMs(model, 65_000)).toBe(65_000); + const later = applyLiveTiming(model, 65_000) as DashboardModel; + expect(later.totalActiveMs).toBe(65_000); + }); + + it("freezes the clock while paused", () => { + const timer = timerWith( + [{ stage: "intake", enteredAt: 0, activeMs: 0 }], + 30_000, + ); + timer.pausedAt = 30_000; + const model = buildPlannerDashboardModel({ + context: readyContext( + { stage: "intake", step: "await_goal_approval" }, + { timer }, + ), + now: 30_000, + }) as DashboardModel; + expect(liveTotalMs(model, 90_000)).toBe(30_000); + }); +}); + +describe("renderContextLine", () => { + it("shows the active task and branch, clipped with an ellipsis", () => { + const model = buildPlannerDashboardModel({ + context: readyContext( + { + stage: "execution", + step: "implement_task", + activeTaskId: "task-3", + currentBranch: "task/plan-a/task-3", + }, + { + tasks: [ + { taskId: "task-3", title: "Add codec fix", status: "active" }, + ], + }, + ), + now: 1000, + }) as DashboardModel; + const wide = renderContextLine(model, 80, identityPalette); + expect(wide.length).toBe(80); + expect(wide).toContain("task-3: Add codec fix"); + const narrow = renderContextLine(model, 16, identityPalette); + expect(narrow.length).toBe(16); + expect(narrow).toContain("…"); + }); +}); + +describe("composeDashboard", () => { + const tasks: PlanTaskSummary[] = Array.from({ length: 12 }, (_, i) => ({ + taskId: `task-${i + 1}`, + title: `Task number ${i + 1}`, + status: i < 3 ? "done" : i === 3 ? "active" : "pending", + })); + + it("produces exactly height lines bounded to width (full layout)", () => { + const model = buildPlannerDashboardModel({ + context: readyContext( + { stage: "execution", step: "implement_task", activeTaskId: "task-4" }, + { tasks, title: "Codec round-trip" }, + ), + now: 90_000, + }); + const lines = composeDashboard({ + model, + width: 100, + height: 28, + palette: identityPalette, + ui: defaultUi, + }); + expect(lines).toHaveLength(28); + for (const line of lines) { + expect(line.length).toBe(100); + } + }); + + it("produces a bounded compact layout on narrow terminals", () => { + const model = buildPlannerDashboardModel({ + context: readyContext( + { stage: "discovery", step: "write_questions" }, + { tasks }, + ), + now: 30_000, + }); + const lines = composeDashboard({ + model, + width: 48, + height: 14, + palette: identityPalette, + ui: defaultUi, + }); + expect(lines).toHaveLength(14); + for (const line of lines) { + expect(line.length).toBe(48); + } + }); + + it("renders an unavailable frame when no plan is active", () => { + const model = buildPlannerDashboardModel({ + context: unavailableContext("no_active_plan"), + now: 1000, + }); + const lines = composeDashboard({ + model, + width: 60, + height: 12, + palette: identityPalette, + ui: defaultUi, + }); + expect(lines).toHaveLength(12); + expect(lines.join("\n")).toContain("/planner-create"); + }); +}); diff --git a/src/runtime/dashboard-model.ts b/src/runtime/dashboard-model.ts new file mode 100644 index 0000000..dc81acf --- /dev/null +++ b/src/runtime/dashboard-model.ts @@ -0,0 +1,1054 @@ +import { + PLANNER_STAGE_STEPS, + type PlannerStage, + type PlannerStep, + type PlanStateRecord, + type PlanStatus, + type StepStatus, + type TaskStatus, +} from "../storage/schema"; +import type { ActivePlanContext } from "./active-plan"; + +/** + * Pure data + rendering layer for the planner dashboard TUI. + * + * Everything here is free of terminal/theme imports so it can be unit tested + * with an identity palette. The interactive component in dashboard.ts injects a + * real palette (theme colors + ANSI-aware width helpers) and terminal size. + */ + +export const DASHBOARD_STAGE_SEQUENCE = [ + "init", + "intake", + "discovery", + "planning", + "execution", + "finalize", + "done", +] as const satisfies readonly PlannerStage[]; + +const STAGE_LABEL: Record = { + init: "INIT", + intake: "INTAKE", + discovery: "DISCOVERY", + planning: "PLANNING", + execution: "EXECUTION", + finalize: "FINALIZE", + done: "DONE", + recovery: "RECOVERY", +}; + +export type DashboardStatusLabel = + | "run" + | "wait" + | "done" + | "stuck" + | "recovery"; + +export interface DashboardTaskRow { + taskId: string; + title: string; + status: TaskStatus; + isActive: boolean; +} + +export interface DashboardStageTiming { + stage: PlannerStage; + activeMs: number; + isCurrent: boolean; +} + +export interface DashboardModel { + available: true; + planId: string; + planTitle: string; + planStatus: PlanStatus; + stage: PlannerStage; + step: PlannerStep; + stepStatus: StepStatus; + /** Index of the highlighted stage within DASHBOARD_STAGE_SEQUENCE. */ + stageIndex: number; + /** Index of the current step within its stage (0-based). */ + stepIndex: number; + /** Total steps in the current stage. */ + stepCount: number; + /** Overall progress across all stages, 0..1. */ + overallRatio: number; + /** Progress within the current stage, 0..1. */ + stageRatio: number; + recovery: boolean; + blocked: boolean; + stuck: boolean; + statusLabel: DashboardStatusLabel; + activeTaskId: string | null; + currentBranch: string | null; + tasks: DashboardTaskRow[]; + tasksDone: number; + tasksTotal: number; + totalActiveMs: number; + timings: DashboardStageTiming[]; + routeTrail: PlannerStage[]; + note: string | null; + /** Inputs for recomputing live elapsed time without re-reading disk. */ + live: { + base: number; + syncedAt: number; + running: boolean; + syncMs: number; + checkpoints: { stage: PlannerStage; activeMs: number }[]; + timingStage: PlannerStage; + }; +} + +export interface DashboardUnavailable { + available: false; + reason: string; + hint: string; +} + +export type PlannerDashboardModel = DashboardModel | DashboardUnavailable; + +export interface DashboardPalette { + text(s: string): string; + accent(s: string): string; + muted(s: string): string; + dim(s: string): string; + success(s: string): string; + warning(s: string): string; + error(s: string): string; + border(s: string): string; + bold(s: string): string; + inverse(s: string): string; + stage(stage: PlannerStage, s: string): string; + /** Visible width of a string, ignoring ANSI escapes. */ + measure(s: string): number; + /** Truncate to a visible width, ANSI-safe. */ + clip(s: string, width: number): string; +} + +export interface DashboardUiState { + selectedIndex: number; + taskScroll: number; + focus: "tasks" | "ribbon"; +} + +/** Default timer sync interval (ms) used to cap live elapsed when unknown. */ +const DEFAULT_TIMER_SYNC_MS = 600_000; + +export function buildPlannerDashboardModel(input: { + context: ActivePlanContext; + now: number; + /** Timer sync interval in ms; caps live elapsed since the last disk sync. */ + syncMs?: number; +}): PlannerDashboardModel { + const { context } = input; + if (context.status !== "ready") { + return { + available: false, + reason: unavailableReason(context.status), + hint: + context.status === "no_active_plan" || + context.status === "missing_project" + ? "Run /planner-create to start a plan, then reopen this dashboard." + : "Run /planner-resume to restore the active plan worktree.", + }; + } + + const state = context.state; + const recovery = state.stage === "recovery"; + const routeTrail = buildRouteTrail(state); + const effectiveStage = recovery + ? lastSequencedStage(routeTrail) + : state.stage; + const stageIndex = DASHBOARD_STAGE_SEQUENCE.indexOf( + effectiveStage as (typeof DASHBOARD_STAGE_SEQUENCE)[number], + ); + const stageSteps = PLANNER_STAGE_STEPS[state.stage] as readonly PlannerStep[]; + const stepIndex = Math.max(0, stageSteps.indexOf(state.step)); + const stepCount = stageSteps.length; + const stageRatio = recovery + ? 0 + : clamp01( + (stepIndex + (state.stepStatus === "completed" ? 1 : 0.5)) / stepCount, + ); + const overallRatio = computeOverallRatio(stageIndex, stageRatio, recovery); + + const tasks: DashboardTaskRow[] = context.plan.tasks.map((task) => ({ + taskId: task.taskId, + title: task.title, + status: task.status, + isActive: task.taskId === state.activeTaskId, + })); + const tasksDone = tasks.filter((task) => task.status === "done").length; + + const syncMs = input.syncMs ?? DEFAULT_TIMER_SYNC_MS; + const timer = state.timer; + const clockBase = timer?.activeMs ?? 0; + const clockSyncedAt = timer?.lastSyncedAt ?? input.now; + const clockRunning = timer + ? timer.pausedAt === null && timer.finishedAt === null + : false; + const checkpoints = (timer?.checkpoints ?? []).map((c) => ({ + stage: c.stage, + activeMs: c.activeMs, + })); + const totalActiveMs = liveActiveMs(timer, input.now, syncMs); + const timings = computeStageTimings( + checkpoints, + state.stage, + totalActiveMs, + effectiveStage, + ); + + const stuck = !recovery && state.lastStuckAttemptId !== null; + const blocked = + state.broken || state.requiresUserDecision || state.blockedReason !== null; + const statusLabel = deriveStatusLabel({ + recovery, + stuck, + planStatus: context.plan.status, + stage: state.stage, + blocked, + }); + + return { + available: true, + planId: context.activePlanId, + planTitle: context.plan.title, + planStatus: context.plan.status, + stage: state.stage, + step: state.step, + stepStatus: state.stepStatus, + stageIndex, + stepIndex, + stepCount, + overallRatio, + stageRatio, + recovery, + blocked, + stuck, + statusLabel, + activeTaskId: state.activeTaskId, + currentBranch: state.currentBranch, + tasks, + tasksDone, + tasksTotal: tasks.length, + totalActiveMs, + timings, + routeTrail, + note: state.brokenReason ?? state.blockedReason ?? null, + live: { + base: clockBase, + syncedAt: clockSyncedAt, + running: clockRunning, + syncMs, + checkpoints, + timingStage: effectiveStage, + }, + }; +} + +/** Live total active ms from a built model, recomputed against `now`. */ +export function liveTotalMs(model: DashboardModel, now: number): number { + const { base, syncedAt, running, syncMs } = model.live; + if (!running) return base; + return base + Math.max(0, Math.min(now - syncedAt, syncMs)); +} + +/** + * Recompute the clock and stage timings against `now` without touching disk, so + * the terminal display ticks in real time while the persisted timer only syncs + * occasionally. + */ +export function applyLiveTiming( + model: PlannerDashboardModel, + now: number, +): PlannerDashboardModel { + if (!model.available) return model; + const total = liveTotalMs(model, now); + return { + ...model, + totalActiveMs: total, + timings: computeStageTimings( + model.live.checkpoints, + model.stage, + total, + model.live.timingStage, + ), + }; +} + +function deriveStatusLabel(input: { + recovery: boolean; + stuck: boolean; + planStatus: PlanStatus; + stage: PlannerStage; + blocked: boolean; +}): DashboardStatusLabel { + if (input.recovery) return "recovery"; + if (input.stuck) return "stuck"; + if (input.planStatus === "done" || input.stage === "done") return "done"; + if (input.blocked) return "wait"; + return "run"; +} + +/** + * Live total active time: the persisted activeMs plus the time elapsed since the + * last disk sync (capped to one sync interval, mirroring the footer timer). The + * timer state is only written to disk every few minutes, so reading activeMs + * directly would make the dashboard clock appear frozen. + */ +function liveActiveMs( + timer: PlanStateRecord["timer"], + now: number, + syncMs: number, +): number { + if (!timer) return 0; + if (timer.pausedAt !== null || timer.finishedAt !== null) { + return timer.activeMs; + } + return ( + timer.activeMs + Math.max(0, Math.min(now - timer.lastSyncedAt, syncMs)) + ); +} + +function buildRouteTrail(state: PlanStateRecord): PlannerStage[] { + const checkpoints = state.timer?.checkpoints ?? []; + const trail: PlannerStage[] = []; + for (const checkpoint of checkpoints) { + if (trail[trail.length - 1] !== checkpoint.stage) { + trail.push(checkpoint.stage); + } + } + if (trail[trail.length - 1] !== state.stage) { + trail.push(state.stage); + } + return trail; +} + +function lastSequencedStage(trail: PlannerStage[]): PlannerStage { + for (let i = trail.length - 1; i >= 0; i--) { + const stage = trail[i]; + if (stage !== "recovery") return stage; + } + return "init"; +} + +interface TimingCheckpoint { + stage: PlannerStage; + activeMs: number; +} + +function computeStageTimings( + checkpoints: TimingCheckpoint[], + planStage: PlannerStage, + totalActiveMs: number, + currentStage: PlannerStage, +): DashboardStageTiming[] { + const perStage = new Map(); + for (let i = 0; i < checkpoints.length; i++) { + const start = checkpoints[i].activeMs; + const end = checkpoints[i + 1]?.activeMs ?? totalActiveMs; + const delta = Math.max(0, end - start); + perStage.set( + checkpoints[i].stage, + (perStage.get(checkpoints[i].stage) ?? 0) + delta, + ); + } + if (perStage.size === 0 && totalActiveMs > 0) { + perStage.set(planStage, totalActiveMs); + } + const order: PlannerStage[] = [...DASHBOARD_STAGE_SEQUENCE]; + if (planStage === "recovery") order.push("recovery"); + return order + .filter((stage) => perStage.has(stage)) + .map((stage) => ({ + stage, + activeMs: perStage.get(stage) ?? 0, + isCurrent: stage === currentStage || stage === planStage, + })); +} + +function computeOverallRatio( + stageIndex: number, + stageRatio: number, + recovery: boolean, +): number { + if (stageIndex < 0) return 0; + const total = DASHBOARD_STAGE_SEQUENCE.length; + const base = (stageIndex + (recovery ? 0 : stageRatio)) / total; + return clamp01(base); +} + +function unavailableReason(status: ActivePlanContext["status"]): string { + switch (status) { + case "missing_project": + return "No planner project exists for this directory yet."; + case "no_active_plan": + return "No active planner plan."; + case "missing_plan": + return "The active plan record is missing."; + case "missing_state": + return "The active plan runtime state is missing."; + default: + return "Planner dashboard is unavailable."; + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const MIN_FULL_WIDTH = 64; +const MIN_FULL_HEIGHT = 18; + +export function composeDashboard(input: { + model: PlannerDashboardModel; + width: number; + height: number; + palette: DashboardPalette; + ui: DashboardUiState; +}): string[] { + const { model, palette } = input; + const width = Math.max(20, Math.floor(input.width)); + const height = Math.max(8, Math.floor(input.height)); + const inner = width - 2; + + if (!model.available) { + return frame({ + palette, + width, + height, + title: "Planner for Local Models", + clock: "", + body: renderUnavailable(model, inner, palette), + }); + } + + const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT; + const clock = formatClock(model.totalActiveMs); + const body = compact + ? renderCompactBody({ model, inner, height, palette, ui: input.ui }) + : renderFullBody({ model, inner, height, palette, ui: input.ui }); + + return frame({ + palette, + width, + height, + title: dashboardTitle(model), + clock, + body, + }); +} + +function dashboardTitle(model: DashboardModel): string { + return `Planner · ${model.planTitle}`; +} + +function renderUnavailable( + model: DashboardUnavailable, + inner: number, + palette: DashboardPalette, +): string[] { + return [ + "", + padTo(palette.warning(model.reason), inner, palette), + "", + padTo(palette.dim(model.hint), inner, palette), + "", + padTo(palette.dim("Press q or esc to close."), inner, palette), + ]; +} + +interface BodyInput { + model: DashboardModel; + inner: number; + height: number; + palette: DashboardPalette; + ui: DashboardUiState; +} + +function renderFullBody(input: BodyInput): string[] { + const { model, inner, palette, ui } = input; + const lines: string[] = []; + // Header line: status + step position. + lines.push(renderHeaderLine(model, inner, palette)); + lines.push(blank(inner, palette)); + // Stage ribbon (progress bar) + ticker. + lines.push(...renderStageRibbon(model, inner, palette)); + lines.push(renderContextLine(model, inner, palette)); + lines.push(divider(inner, palette)); + + // Body height available for the two-column area. + // frame uses: top + header(1) + blank(1) + ribbon(2) + ticker(1) + + // divider(1) ... + help(1) + bottom. Reserve rows for help + already pushed. + const used = lines.length; + const reserveBelow = 2; // help line + spacing + const columnHeight = Math.max( + 3, + input.height - 2 /* borders */ - used - reserveBelow, + ); + + const leftWidth = Math.max(22, Math.floor(inner * 0.42)); + const rightWidth = inner - leftWidth - 3; // 3 = " │ " separator + const left = renderTaskColumn(model, { + width: leftWidth, + height: columnHeight, + palette, + ui, + }); + const right = renderDetailColumn(model, { + width: rightWidth, + height: columnHeight, + palette, + }); + + for (let i = 0; i < columnHeight; i++) { + const l = padTo(left[i] ?? "", leftWidth, palette); + const r = padTo(right[i] ?? "", rightWidth, palette); + lines.push(`${l} ${palette.border("│")} ${r}`); + } + + lines.push(blank(inner, palette)); + lines.push(renderHelpLine(inner, palette, ui)); + return lines; +} + +function renderCompactBody(input: BodyInput): string[] { + const { model, inner, palette, ui } = input; + const lines: string[] = []; + lines.push(renderHeaderLine(model, inner, palette)); + lines.push(...renderStageRibbon(model, inner, palette)); + lines.push(renderContextLine(model, inner, palette)); + lines.push(divider(inner, palette)); + + const used = lines.length; + const reserveBelow = 1; + const listHeight = Math.max(2, input.height - 2 - used - reserveBelow); + lines.push( + ...renderTaskColumn(model, { + width: inner, + height: listHeight, + palette, + ui, + }), + ); + lines.push(renderHelpLine(inner, palette, ui)); + return lines; +} + +/** + * Compact top band for the workspace: header + stage ribbon + ticker. + * Returns content lines only (no outer frame). + */ +export function renderDashboardBand( + model: PlannerDashboardModel, + inner: number, + palette: DashboardPalette, +): string[] { + if (!model.available) { + return [padTo(palette.warning(model.reason), inner, palette)]; + } + return [ + renderHeaderLine(model, inner, palette), + ...renderStageRibbon(model, inner, palette), + renderContextLine(model, inner, palette), + ]; +} + +/** + * Expanded dashboard area: task list + detail/timings columns. + * Returns exactly `height` content lines (no outer frame). + */ +export function renderDashboardColumns( + model: DashboardModel, + inner: number, + height: number, + palette: DashboardPalette, + ui: DashboardUiState, +): string[] { + const leftWidth = Math.max(22, Math.floor(inner * 0.42)); + const rightWidth = inner - leftWidth - 3; + const left = renderTaskColumn(model, { + width: leftWidth, + height, + palette, + ui, + }); + const right = renderDetailColumn(model, { + width: rightWidth, + height, + palette, + }); + const lines: string[] = []; + for (let i = 0; i < height; i++) { + const l = padTo(left[i] ?? "", leftWidth, palette); + const r = padTo(right[i] ?? "", rightWidth, palette); + lines.push(`${l} ${palette.border("│")} ${r}`); + } + return lines; +} + +function renderHeaderLine( + model: DashboardModel, + inner: number, + palette: DashboardPalette, +): string { + const badge = statusBadge(model.statusLabel, palette); + const position = model.recovery + ? palette.error("RECOVERY") + : palette.muted( + `${STAGE_LABEL[model.stage]} ${palette.dim("·")} step ${model.stepIndex + 1}/${model.stepCount}`, + ); + const left = `${badge} ${position}`; + const tasks = palette.dim(`tasks ${model.tasksDone}/${model.tasksTotal}`); + return spread(left, tasks, inner, palette); +} + +export function renderStageRibbon( + model: DashboardModel, + width: number, + palette: DashboardPalette, +): string[] { + const sep = palette.dim(" › "); + const total = DASHBOARD_STAGE_SEQUENCE.length; + const parts = DASHBOARD_STAGE_SEQUENCE.map((stage, index) => { + const label = STAGE_LABEL[stage]; + if (index === model.stageIndex && !model.recovery) { + // Active stage: bracketed + bold in the stage colour. + return palette.bold(palette.stage(stage, `[${label}]`)); + } + if (index < model.stageIndex) { + // Completed stage: stage colour. + return palette.stage(stage, label); + } + // Pending stage: dimmed. + return palette.dim(label); + }); + const full = parts.join(sep); + if (palette.measure(full) <= width) { + return [padTo(full, width, palette)]; + } + // Too narrow for the whole ribbon: keep the active stage always visible. + const activeStage = + DASHBOARD_STAGE_SEQUENCE[Math.max(0, model.stageIndex)] ?? "init"; + const position = model.recovery + ? palette.error("RECOVERY") + : palette.bold(palette.stage(activeStage, `[${STAGE_LABEL[activeStage]}]`)); + const counter = palette.dim(` ${Math.max(1, model.stageIndex + 1)}/${total}`); + return [padTo(position + counter, width, palette)]; +} + +/** + * Static one-line context under the ribbon. Shows only what the header/ribbon + * do not already convey — a blocking note, the active task, and the branch — + * clipped with an ellipsis. No marquee: a constantly scrolling line forced a + * full repaint every tick and burned CPU for little value. + */ +export function renderContextLine( + model: DashboardModel, + width: number, + palette: DashboardPalette, +): string { + const sep = palette.dim(" · "); + const parts: string[] = []; + if (model.recovery) + parts.push(palette.error("⚠ recovery — resolve to resume")); + else if (model.note) parts.push(palette.warning(`⚠ ${model.note}`)); + if (model.activeTaskId) { + const title = taskTitle(model, model.activeTaskId); + parts.push( + palette.text(`${model.activeTaskId}${title ? `: ${title}` : ""}`), + ); + } + if (model.currentBranch) parts.push(palette.dim(model.currentBranch)); + if (parts.length === 0) + parts.push(palette.dim(`${model.stage}/${model.step}`)); + return padTo(clipEllipsis(parts.join(sep), width, palette), width, palette); +} + +function clipEllipsis( + value: string, + width: number, + palette: DashboardPalette, +): string { + if (palette.measure(value) <= width) return value; + return `${palette.clip(value, Math.max(0, width - 1))}…`; +} + +interface ColumnInput { + width: number; + height: number; + palette: DashboardPalette; + ui: DashboardUiState; +} + +export function renderTaskColumn( + model: DashboardModel, + input: ColumnInput, +): string[] { + const { width, height, palette, ui } = input; + const lines: string[] = []; + const headerFocus = + ui.focus === "tasks" ? palette.accent("TASKS") : palette.muted("TASKS"); + lines.push( + spread( + headerFocus, + palette.dim(`${model.tasksDone}/${model.tasksTotal} done`), + width, + palette, + ), + ); + + const rowsAvailable = Math.max(1, height - 1); + if (model.tasks.length === 0) { + lines.push(padTo(palette.dim("(no tasks yet)"), width, palette)); + return fillColumn(lines, height, width, palette); + } + + // Auto-follow the selection so the highlighted task is always visible, + // keeping it roughly centred within the visible window. + const centered = ui.selectedIndex - Math.floor(rowsAvailable / 2); + const scroll = clampScroll(centered, model.tasks.length, rowsAvailable); + const end = Math.min(model.tasks.length, scroll + rowsAvailable); + for (let i = scroll; i < end; i++) { + lines.push( + renderTaskRow(model.tasks[i], i === ui.selectedIndex, width, palette), + ); + } + if (model.tasks.length > rowsAvailable) { + const lastLine = lines.length - 1; + const indicator = palette.dim( + `${Math.min(end, model.tasks.length)}/${model.tasks.length}`, + ); + lines[lastLine] = spread( + stripTrailing(lines[lastLine], palette), + indicator, + width, + palette, + ); + } + return fillColumn(lines, height, width, palette); +} + +function renderTaskRow( + task: DashboardTaskRow, + selected: boolean, + width: number, + palette: DashboardPalette, +): string { + const cursor = selected ? palette.accent("▸") : " "; + const icon = taskStatusIcon(task.status, palette); + const idAndTitle = `${task.taskId} ${task.title}`; + const prefixWidth = 4; // cursor + space + icon + space + const body = palette.clip( + selected + ? palette.bold(idAndTitle) + : task.isActive + ? palette.text(idAndTitle) + : palette.muted(idAndTitle), + Math.max(1, width - prefixWidth), + ); + return padTo(`${cursor} ${icon} ${body}`, width, palette); +} + +function taskStatusIcon(status: TaskStatus, palette: DashboardPalette): string { + switch (status) { + case "done": + return palette.success("✓"); + case "active": + return palette.accent("●"); + case "blocked": + return palette.error("✗"); + default: + return palette.dim("○"); + } +} + +export function renderDetailColumn( + model: DashboardModel, + input: { width: number; height: number; palette: DashboardPalette }, +): string[] { + const { width, height, palette } = input; + const lines: string[] = []; + lines.push( + spread( + palette.muted("CURRENT"), + statusBadge(model.statusLabel, palette), + width, + palette, + ), + ); + lines.push( + padTo(palette.text(`${model.stage} / ${model.step}`), width, palette), + ); + lines.push( + padTo( + palette.dim(`step ${model.stepIndex + 1} of ${model.stepCount}`), + width, + palette, + ), + ); + if (model.activeTaskId) { + lines.push( + padTo(palette.dim(`task ${model.activeTaskId}`), width, palette), + ); + } + if (model.currentBranch) { + lines.push( + padTo( + palette.dim(palette.clip(`↳ ${model.currentBranch}`, width)), + width, + palette, + ), + ); + } + lines.push(blank(width, palette)); + lines.push(padTo(palette.muted("STAGE TIMINGS"), width, palette)); + for (const timing of model.timings) { + const name = STAGE_LABEL[timing.stage].toLowerCase().padEnd(10); + const clock = formatDuration(timing.activeMs); + const marker = timing.isCurrent ? palette.accent(" ◂ now") : ""; + const line = `${name} ${clock}${marker}`; + lines.push( + padTo( + timing.isCurrent ? palette.text(line) : palette.dim(line), + width, + palette, + ), + ); + } + if (model.note) { + lines.push(blank(width, palette)); + const remaining = Math.max(1, height - lines.length); + const noteLines = wrapWords(model.note, width, remaining); + for (const line of noteLines) { + lines.push(padTo(palette.warning(line), width, palette)); + } + } + return fillColumn(lines, height, width, palette); +} + +/** + * Greedy word wrap into at most `maxLines` lines; the last line is ellipsised + * when the text does not fit. + */ +function wrapWords(text: string, width: number, maxLines: number): string[] { + const words = text.replace(/\s+/g, " ").trim().split(" "); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= width) { + current = candidate; + continue; + } + if (current) lines.push(current); + current = word.length > width ? word.slice(0, width) : word; + if (lines.length >= maxLines) break; + } + if (current && lines.length < maxLines) lines.push(current); + if (lines.length > maxLines) lines.length = maxLines; + const overflow = + lines.length === maxLines && joinedLength(lines) < text.length; + if (overflow) { + const last = lines[maxLines - 1]; + lines[maxLines - 1] = + last.length >= width + ? `${last.slice(0, Math.max(0, width - 1))}…` + : `${last}…`; + } + return lines; +} + +function joinedLength(lines: string[]): number { + return lines.reduce((sum, line) => sum + line.length + 1, 0); +} + +function renderHelpLine( + inner: number, + palette: DashboardPalette, + ui: DashboardUiState, +): string { + const tab = + ui.focus === "tasks" + ? `${palette.accent("tasks")} ${palette.dim("ribbon")}` + : `${palette.dim("tasks")} ${palette.accent("ribbon")}`; + const keys = palette.dim( + "↑↓ select · ←→ scroll · tab focus · r refresh · q/esc close", + ); + return spread(tab, keys, inner, palette); +} + +function statusBadge( + label: DashboardStatusLabel, + palette: DashboardPalette, +): string { + switch (label) { + case "run": + return palette.success("● RUN"); + case "wait": + return palette.warning("⏸ WAIT"); + case "done": + return palette.success("✓ DONE"); + case "stuck": + return palette.error("▲ STUCK"); + case "recovery": + return palette.error("⟳ RECOVERY"); + } +} + +// --------------------------------------------------------------------------- +// Frame + layout primitives +// --------------------------------------------------------------------------- + +/** Wrap body content lines in the planner frame (title + clock + borders). */ +export function frameWorkspace(input: { + palette: DashboardPalette; + width: number; + height: number; + title: string; + clock: string; + body: string[]; +}): string[] { + return frame(input); +} + +/** Inner divider line (full inner width). */ +export function dashboardDivider( + inner: number, + palette: DashboardPalette, +): string { + return divider(inner, palette); +} + +function frame(input: { + palette: DashboardPalette; + width: number; + height: number; + title: string; + clock: string; + body: string[]; +}): string[] { + const { palette, width, height } = input; + const inner = width - 2; + const top = renderTopBorder(input.title, input.clock, inner, palette); + const bottom = palette.border(`╰${"─".repeat(inner)}╯`); + + const bodyHeight = Math.max(0, height - 2); + const body: string[] = []; + for (let i = 0; i < bodyHeight; i++) { + const line = input.body[i] ?? blank(inner, palette); + body.push( + `${palette.border("│")}${padTo(line, inner, palette)}${palette.border("│")}`, + ); + } + return [top, ...body, bottom]; +} + +function renderTopBorder( + title: string, + clock: string, + inner: number, + palette: DashboardPalette, +): string { + const titleText = palette.clip(` ${title} `, Math.max(0, inner - 4)); + const clockText = clock ? ` ${clock} ` : ""; + const dashes = Math.max( + 0, + inner - palette.measure(titleText) - palette.measure(clockText) - 2, + ); + return ( + palette.border("╭─") + + palette.bold(palette.accent(titleText)) + + palette.border("─".repeat(dashes)) + + (clock ? palette.dim(clockText) : "") + + palette.border("─╮") + ); +} + +function divider(inner: number, palette: DashboardPalette): string { + return palette.border("─".repeat(inner)); +} + +function blank(width: number, palette: DashboardPalette): string { + return padTo("", width, palette); +} + +function fillColumn( + lines: string[], + height: number, + width: number, + palette: DashboardPalette, +): string[] { + const out = lines.slice(0, height); + while (out.length < height) out.push(blank(width, palette)); + return out.map((line) => padTo(line, width, palette)); +} + +function padTo( + value: string, + width: number, + palette: DashboardPalette, +): string { + const clipped = palette.clip(value, width); + const pad = Math.max(0, width - palette.measure(clipped)); + return clipped + " ".repeat(pad); +} + +function spread( + left: string, + right: string, + width: number, + palette: DashboardPalette, +): string { + const lw = palette.measure(left); + const rw = palette.measure(right); + if (lw + rw + 1 > width) { + return padTo(left, width, palette); + } + const gap = width - lw - rw; + return left + " ".repeat(gap) + right; +} + +function stripTrailing(line: string, palette: DashboardPalette): string { + // Best-effort: trim trailing spaces so spread can re-justify. + let end = line.length; + while (end > 0 && line[end - 1] === " ") end--; + void palette; + return line.slice(0, end); +} + +function taskTitle(model: DashboardModel, taskId: string): string { + return model.tasks.find((task) => task.taskId === taskId)?.title ?? ""; +} + +function clampScroll(scroll: number, total: number, visible: number): number { + const maxScroll = Math.max(0, total - visible); + return Math.min(Math.max(0, scroll), maxScroll); +} + +function clamp01(value: number): number { + if (Number.isNaN(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +export function formatClock(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return [hours, minutes, seconds] + .map((value) => value.toString().padStart(2, "0")) + .join(":"); +} + +export function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h${minutes.toString().padStart(2, "0")}m`; + if (minutes > 0) return `${minutes}m${seconds.toString().padStart(2, "0")}s`; + return `${seconds}s`; +} diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts new file mode 100644 index 0000000..2cf8164 --- /dev/null +++ b/src/runtime/dashboard.ts @@ -0,0 +1,822 @@ +import { + type ExtensionAPI, + type ExtensionContext, + getAgentDir, + type KeybindingsManager, + type SessionEntry, + type Theme, + type ThemeColor, +} from "@earendil-works/pi-coding-agent"; +import { + type Component, + type KeyId, + matchesKey, + type TUI, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; +import { loadEffectivePlannerSettings } from "../settings/manager"; +import { createNodeFs, type PlannerFs } from "../storage/fs"; +import type { ProjectStoragePaths } from "../storage/paths"; +import { resolveProjectStoragePaths } from "../storage/project-resolver"; +import type { PlannerStage } from "../storage/schema"; +import { readActivePlanContext } from "./active-plan"; +import { + type ChatRow, + projectLiveAssistant, + projectSessionEntries, + renderTranscript, +} from "./chat-view"; +import { + applyLiveTiming, + buildPlannerDashboardModel, + type DashboardPalette, + type DashboardUiState, + dashboardDivider, + formatClock, + frameWorkspace, + liveTotalMs, + type PlannerDashboardModel, + renderDashboardBand, + renderDashboardColumns, +} from "./dashboard-model"; + +const TICK_MS = 180; +/** + * Reload structural model state (tasks, stage) from disk every Nth tick (~3s). + * The clock and stage timings tick live in-memory between reloads, so we do not + * hit disk every second. + */ +const RELOAD_EVERY_TICKS = 16; +/** Rows left for Pi's native footer below the workspace overlay. */ +const DEFAULT_FOOTER_RESERVE = 3; +/** + * How many trailing session entries to project at a time. The transcript shows + * a sliding window over the conversation; scrolling to the top loads another + * chunk so very long sessions never project everything at once. + */ +const HISTORY_WINDOW = 400; +/** Minimum gap between streaming-driven redraws (~12 fps). */ +const STREAM_THROTTLE_MS = 80; + +type WorkspaceAction = + | "focusNext" + | "up" + | "down" + | "pageUp" + | "pageDown" + | "jumpBottom" + | "jumpTop" + | "expand" + | "submit" + | "exit"; + +/** Built-in workspace keys; overridable via settings workspace.keys. */ +const DEFAULT_WORKSPACE_KEYS: Record = { + focusNext: ["tab"], + up: ["up"], + down: ["down"], + pageUp: ["pageUp"], + pageDown: ["pageDown"], + jumpBottom: ["end"], + jumpTop: ["home"], + expand: ["x"], + submit: ["enter"], + exit: ["escape"], +}; + +function resolveWorkspaceKeys( + overrides: Partial> | undefined, +): Record { + const resolved = { ...DEFAULT_WORKSPACE_KEYS }; + if (overrides) { + for (const action of Object.keys( + DEFAULT_WORKSPACE_KEYS, + ) as WorkspaceAction[]) { + const keys = overrides[action]; + if (keys && keys.length > 0) resolved[action] = keys; + } + } + return resolved; +} + +const STAGE_THEME_COLOR: Record = { + init: "syntaxComment", + intake: "syntaxKeyword", + discovery: "syntaxFunction", + planning: "syntaxType", + execution: "syntaxString", + finalize: "syntaxNumber", + done: "success", + recovery: "error", +}; + +/** + * Latest in-flight assistant message while the model is streaming. Updated by + * message_update events and cleared at message_end, so the workspace can show + * token-by-token output before the entry is committed to the session. + */ +let liveAssistantMessage: unknown | null = null; +/** Notifies the open workspace (if any) to redraw on each streaming token. */ +let liveStreamListener: (() => void) | null = null; + +export function registerPlannerDashboard(pi: ExtensionAPI): void { + pi.registerCommand("planner-dashboard", { + description: + "Open the planner workspace: live stage dashboard, task list, and the model chat in one window.", + handler: async (_args, ctx) => { + await openPlannerWorkspace(pi, ctx); + }, + }); + + // Track streaming assistant output and redraw the workspace per token so the + // chat fills in smoothly, matching Pi's own streaming feel. + pi.on("message_update", (event) => { + liveAssistantMessage = (event as { message?: unknown }).message ?? null; + liveStreamListener?.(); + }); + pi.on("message_end", () => { + liveAssistantMessage = null; + liveStreamListener?.(); + }); +} + +export async function openPlannerWorkspace( + pi: ExtensionAPI, + ctx: ExtensionContext, + options: { auto?: boolean } = {}, +): Promise { + if (!ctx.hasUI) { + if (!options.auto) { + ctx.ui.notify( + "The planner workspace requires interactive mode.", + "error", + ); + } + return; + } + const fs = createNodeFs(); + const config = await loadWorkspaceSettings(fs, ctx.cwd); + if (!config.enabled) { + if (!options.auto) { + ctx.ui.notify( + "The planner workspace is disabled (settings: workspace.enabled).", + "info", + ); + } + return; + } + if (options.auto && !config.autoOpen) return; + const footerReserve = Math.max(0, config.footerReserveRows); + const load = () => loadDashboardModel(fs, ctx.cwd, config.syncMs); + const getEntries = () => ctx.sessionManager.getBranch(); + const initial = await load(); + await ctx.ui.custom( + (tui, theme, keybindings, done) => { + return new PlannerWorkspaceComponent({ + tui, + theme, + keybindings, + keys: config.keys, + initial, + footerReserve, + load, + getEntries, + sendUserMessage: (text) => pi.sendUserMessage(text), + onClose: () => done(undefined), + }); + }, + { + // Render as a fixed top overlay so the workspace does not live in the + // chat scrollback (mouse-wheel no longer drags it off-screen) and the + // native footer stays visible in the reserved rows below it. + overlay: true, + overlayOptions: () => { + const rows = process.stdout.rows ?? 40; + const cols = process.stdout.columns ?? 100; + return { + width: cols, + maxHeight: Math.max(16, rows - footerReserve), + anchor: "top-left", + row: 0, + col: 0, + }; + }, + }, + ); +} + +async function loadWorkspaceSettings( + fs: PlannerFs, + cwd: string, +): Promise<{ + enabled: boolean; + autoOpen: boolean; + footerReserveRows: number; + syncMs: number; + keys: Record; +}> { + try { + const projectPaths = await resolveProjectStoragePaths({ + fs, + agentDir: getAgentDir(), + cwd, + }); + const settings = await loadEffectivePlannerSettings({ fs, projectPaths }); + const workspace = settings.effective.workspace; + return { + enabled: workspace.enabled, + autoOpen: workspace.autoOpen, + footerReserveRows: workspace.footerReserveRows, + syncMs: settings.effective.timer.syncIntervalMinutes * 60_000, + keys: resolveWorkspaceKeys(workspace.keys), + }; + } catch { + return { + enabled: true, + autoOpen: true, + footerReserveRows: DEFAULT_FOOTER_RESERVE, + syncMs: 600_000, + keys: resolveWorkspaceKeys(undefined), + }; + } +} + +async function loadDashboardModel( + fs: PlannerFs, + cwd: string, + syncMs: number, +): Promise { + try { + const projectPaths: ProjectStoragePaths = await resolveProjectStoragePaths({ + fs, + agentDir: getAgentDir(), + cwd, + }); + const context = await readActivePlanContext({ fs, projectPaths }); + return buildPlannerDashboardModel({ context, now: Date.now(), syncMs }); + } catch (error) { + return { + available: false, + reason: `Failed to read planner state: ${ + error instanceof Error ? error.message : String(error) + }`, + hint: "Check that a planner plan exists for this directory.", + }; + } +} + +type WorkspaceFocus = "input" | "chat" | "tasks"; + +class PlannerWorkspaceComponent implements Component { + private readonly tui: TUI; + private readonly palette: DashboardPalette; + private readonly load: () => Promise; + private readonly getEntries: () => SessionEntry[]; + private readonly sendUserMessage: (text: string) => void; + private readonly onClose: () => void; + private readonly footerReserve: number; + private readonly keybindings: KeybindingsManager; + private readonly keys: Record; + + private model: PlannerDashboardModel; + private rows: ChatRow[] = []; + private input = ""; + private cursor = 0; + private focus: WorkspaceFocus = "input"; + /** Follow the live tail (true) or hold an absolute scroll position. */ + private atBottom = true; + private topLine = 0; + private expandAll = false; + private hideThinking = false; + // Sliding-window projection state. + private windowEntries = HISTORY_WINDOW; + private hasMoreHistory = false; + private cachedEntryKey = ""; + private cachedBaseRows: ChatRow[] = []; + private readonly ui: DashboardUiState = { + selectedIndex: 0, + taskScroll: 0, + focus: "tasks", + }; + + private interval: ReturnType | null = null; + private tick = 0; + private reloading = false; + private version = 0; + private lastSignature = ""; + private lastStreamRenderAt = 0; + private streamFlushTimer: ReturnType | null = null; + private lastTranscriptTotal = 0; + private lastTranscriptHeight = 1; + private cachedWidth = -1; + private cachedHeight = -1; + private cachedVersion = -1; + private cachedLines: string[] = []; + + constructor(input: { + tui: TUI; + theme: Theme; + keybindings: KeybindingsManager; + keys: Record; + initial: PlannerDashboardModel; + footerReserve: number; + load: () => Promise; + getEntries: () => SessionEntry[]; + sendUserMessage: (text: string) => void; + onClose: () => void; + }) { + this.tui = input.tui; + this.palette = buildPalette(input.theme); + this.keybindings = input.keybindings; + this.keys = input.keys; + this.footerReserve = input.footerReserve; + this.load = input.load; + this.getEntries = input.getEntries; + this.sendUserMessage = input.sendUserMessage; + this.onClose = input.onClose; + this.model = input.initial; + this.refreshRows(); + this.interval = setInterval(() => this.onTick(), TICK_MS); + this.interval.unref?.(); + // Redraw on streaming tokens, throttled so a fast token stream cannot + // drive an unbounded repaint rate. + liveStreamListener = () => this.onStreamUpdate(); + } + + private onTick(): void { + this.refreshRows(); + if (this.tick % RELOAD_EVERY_TICKS === 0) void this.reloadModel(); + this.tick += 1; + // Redraw only when something visible actually changed (clock second, + // content, or focus/input). When nothing changed this is a cheap no-op, + // so the workspace is not CPU-bound while idle. + this.renderIfChanged(); + } + + private onStreamUpdate(): void { + const now = Date.now(); + const elapsed = now - this.lastStreamRenderAt; + if (elapsed >= STREAM_THROTTLE_MS) { + this.lastStreamRenderAt = now; + this.refreshRows(); + this.renderIfChanged(); + return; + } + if (!this.streamFlushTimer) { + this.streamFlushTimer = setTimeout(() => { + this.streamFlushTimer = null; + this.lastStreamRenderAt = Date.now(); + this.refreshRows(); + this.renderIfChanged(); + }, STREAM_THROTTLE_MS - elapsed); + this.streamFlushTimer.unref?.(); + } + } + + private refreshRows(): void { + try { + const entries = this.getEntries(); + const total = entries.length; + const start = Math.max(0, total - this.windowEntries); + this.hasMoreHistory = start > 0; + const lastId = total > 0 ? (entries[total - 1].id ?? "") : ""; + // Reproject only when the windowed slice actually changed, so we do not + // rebuild the whole transcript on every 180ms tick. + const key = `${total}:${start}:${lastId}`; + if (key !== this.cachedEntryKey) { + this.cachedBaseRows = projectSessionEntries(entries.slice(start)); + this.cachedEntryKey = key; + } + this.rows = liveAssistantMessage + ? [ + ...this.cachedBaseRows, + ...projectLiveAssistant(liveAssistantMessage), + ] + : this.cachedBaseRows; + } catch { + // Keep last rows on transient read failure. + } + } + + /** Load the next older chunk of history when scrolled to the top. */ + private growHistory(): void { + if (!this.hasMoreHistory) return; + this.windowEntries += HISTORY_WINDOW; + this.cachedEntryKey = ""; + this.refreshRows(); + } + + private async reloadModel(): Promise { + if (this.reloading) return; + this.reloading = true; + try { + this.model = await this.load(); + this.clampSelection(); + this.renderIfChanged(); + } catch { + // Best-effort. + } finally { + this.reloading = false; + } + } + + private renderIfChanged(): void { + const signature = this.computeSignature(); + if (signature === this.lastSignature) return; + this.scheduleRender(signature); + } + + private computeSignature(): string { + const last = this.rows[this.rows.length - 1]; + const rowsSig = `${this.rows.length}:${last?.key ?? ""}:${last?.text.length ?? 0}`; + const clock = this.model.available + ? formatClock(liveTotalMs(this.model, Date.now())) + : "x"; + const modelSig = this.model.available + ? `${this.model.stage}/${this.model.step}/${this.model.stepStatus}/${this.model.tasksDone}/${this.model.tasksTotal}` + : "unavailable"; + const uiSig = `${this.focus}|${this.input}|${this.cursor}|${this.ui.selectedIndex}|${this.atBottom}:${this.topLine}|${this.expandAll}|${this.hideThinking}`; + return `${clock}#${rowsSig}#${modelSig}#${uiSig}`; + } + + private scheduleRender(signature = this.computeSignature()): void { + this.lastSignature = signature; + this.version += 1; + this.tui.requestRender(); + } + + private clampSelection(): void { + const total = this.model.available ? this.model.tasks.length : 0; + if (this.ui.selectedIndex > total - 1) { + this.ui.selectedIndex = Math.max(0, total - 1); + } + } + + private matchesAction(action: WorkspaceAction, data: string): boolean { + return this.keys[action].some((key) => matchesKey(data, key as KeyId)); + } + + handleInput(data: string): void { + // ctrl+c always exits as a safety net, regardless of key overrides. + if (this.matchesAction("exit", data) || matchesKey(data, "ctrl+c")) { + this.dispose(); + this.onClose(); + return; + } + if (this.matchesAction("focusNext", data)) { + this.cycleFocus(); + return; + } + // Inherit Pi's own keybindings for thinking visibility and tool expansion. + if (this.keybindings.matches(data, "app.thinking.toggle")) { + this.hideThinking = !this.hideThinking; + this.bump(); + return; + } + if (this.keybindings.matches(data, "app.tools.expand")) { + this.expandAll = !this.expandAll; + this.bump(); + return; + } + if (this.focus === "input") { + this.handleInputFocus(data); + return; + } + if (this.focus === "chat") { + this.handleChatFocus(data); + return; + } + this.handleTasksFocus(data); + } + + private cycleFocus(): void { + this.focus = + this.focus === "input" + ? "chat" + : this.focus === "chat" + ? "tasks" + : "input"; + this.bump(); + } + + private handleInputFocus(data: string): void { + if (this.matchesAction("submit", data)) { + this.submit(); + return; + } + if (matchesKey(data, "backspace")) { + if (this.cursor > 0) { + this.input = + this.input.slice(0, this.cursor - 1) + this.input.slice(this.cursor); + this.cursor -= 1; + this.bump(); + } + return; + } + if (matchesKey(data, "left")) { + this.cursor = Math.max(0, this.cursor - 1); + this.bump(); + return; + } + if (matchesKey(data, "right")) { + this.cursor = Math.min(this.input.length, this.cursor + 1); + this.bump(); + return; + } + const insert = toInsertableText(data); + if (insert) { + this.input = + this.input.slice(0, this.cursor) + + insert + + this.input.slice(this.cursor); + this.cursor += insert.length; + this.bump(); + } + } + + private handleChatFocus(data: string): void { + const page = Math.max(1, this.lastTranscriptHeight - 1); + if (this.matchesAction("up", data)) { + this.scrollBy(-1); + } else if (this.matchesAction("down", data)) { + this.scrollBy(1); + } else if (this.matchesAction("pageUp", data)) { + this.scrollBy(-page); + } else if (this.matchesAction("pageDown", data)) { + this.scrollBy(page); + } else if (this.matchesAction("jumpBottom", data)) { + // Jump back to the live tail (newest output). + this.atBottom = true; + this.bump(); + } else if (this.matchesAction("jumpTop", data)) { + this.atBottom = false; + this.topLine = 0; + if (this.hasMoreHistory) this.growHistory(); + this.bump(); + } else if (this.matchesAction("expand", data)) { + this.expandAll = !this.expandAll; + this.bump(); + } + } + + private handleTasksFocus(data: string): void { + const total = this.model.available ? this.model.tasks.length : 0; + if (this.matchesAction("up", data)) { + this.ui.selectedIndex = Math.max(0, this.ui.selectedIndex - 1); + this.bump(); + } else if (this.matchesAction("down", data)) { + this.ui.selectedIndex = Math.min( + Math.max(0, total - 1), + this.ui.selectedIndex + 1, + ); + this.bump(); + } + } + + /** + * Scroll by `delta` lines (negative = toward older). Anchors to an absolute + * top line so newly streamed content appended below never moves the view. + * Reaching the bottom re-enables tail-following; reaching the top loads more + * history. + */ + private scrollBy(delta: number): void { + const maxTop = Math.max( + 0, + this.lastTranscriptTotal - this.lastTranscriptHeight, + ); + const currentTop = this.atBottom ? maxTop : Math.min(this.topLine, maxTop); + const nextTop = currentTop + delta; + if (nextTop >= maxTop) { + this.atBottom = true; + } else { + this.atBottom = false; + this.topLine = Math.max(0, nextTop); + if (this.topLine === 0 && this.hasMoreHistory) this.growHistory(); + } + this.bump(); + } + + private submit(): void { + const text = this.input.trim(); + if (!text) return; + this.input = ""; + this.cursor = 0; + this.atBottom = true; + try { + this.sendUserMessage(text); + } catch { + // Ignore send failures; the next refresh will reflect agent state. + } + this.bump(); + } + + private bump(): void { + this.scheduleRender(); + } + + render(width: number): string[] { + const height = Math.max(16, this.tui.terminal.rows - this.footerReserve); + if ( + width === this.cachedWidth && + height === this.cachedHeight && + this.version === this.cachedVersion + ) { + return this.cachedLines; + } + + // Recompute the clock + stage timings live (no disk read) each draw. + const model = applyLiveTiming(this.model, Date.now()); + const rows = this.hideThinking + ? this.rows.filter((row) => row.role !== "thinking") + : this.rows; + const inner = width - 2; + const bodyHeight = Math.max(1, height - 2); + const band = renderDashboardBand(model, inner, this.palette); + + const top: string[] = [...band]; + if (this.focus === "tasks" && model.available) { + top.push(dashboardDivider(inner, this.palette)); + const colHeight = Math.min( + 12, + Math.max(4, Math.floor((bodyHeight - band.length) * 0.4)), + ); + top.push( + ...renderDashboardColumns( + model, + inner, + colHeight, + this.palette, + this.ui, + ), + ); + } + top.push(dashboardDivider(inner, this.palette)); + + const bottom: string[] = [ + dashboardDivider(inner, this.palette), + this.renderInputLine(inner), + this.renderHelpLine(inner), + ]; + + const transcriptHeight = Math.max( + 1, + bodyHeight - top.length - bottom.length, + ); + const transcript = renderTranscript( + rows, + { + width: inner, + height: transcriptHeight, + atBottom: this.atBottom, + topLine: this.topLine, + expanded: this.expandedKeys(), + }, + this.palette, + ); + this.lastTranscriptTotal = transcript.totalLines; + this.lastTranscriptHeight = transcriptHeight; + if (!this.atBottom) this.topLine = transcript.topLine; + + const body = [...top, ...transcript.lines, ...bottom]; + const lines = frameWorkspace({ + palette: this.palette, + width, + height, + title: this.title(), + clock: model.available ? formatClock(model.totalActiveMs) : "", + body, + }); + this.cachedWidth = width; + this.cachedHeight = height; + this.cachedVersion = this.version; + this.cachedLines = lines; + return lines; + } + + private expandedKeys(): ReadonlySet { + if (!this.expandAll) return EMPTY_SET; + const keys = new Set(); + for (const row of this.rows) if (row.collapsible) keys.add(row.key); + return keys; + } + + private title(): string { + if (!this.model.available) return "Planner for Local Models"; + return `Planner · ${this.model.planTitle}`; + } + + private renderInputLine(inner: number): string { + const promptText = "› "; + const prompt = + this.focus === "input" + ? this.palette.accent(promptText) + : this.palette.dim(promptText); + let body: string; + if (this.focus === "input") { + const before = this.input.slice(0, this.cursor); + const at = this.input.slice(this.cursor, this.cursor + 1) || " "; + const after = this.input.slice(this.cursor + 1); + body = `${this.palette.text(before)}${this.palette.inverse(at)}${this.palette.text(after)}`; + } else if (this.input) { + body = this.palette.text(this.input); + } else { + body = this.palette.dim("type a message, tab to switch panes"); + } + return clipPad(prompt + body, inner, this.palette); + } + + private renderHelpLine(inner: number): string { + const tabs = (["input", "chat", "tasks"] as WorkspaceFocus[]) + .map((f) => + f === this.focus ? this.palette.accent(f) : this.palette.dim(f), + ) + .join(this.palette.dim(" · ")); + const keys = + this.focus === "input" + ? this.palette.dim("enter send · paste ok · tab pane · esc exit") + : this.focus === "chat" + ? this.palette.dim("↑↓ scroll · end live · x expand · tab pane") + : this.palette.dim("↑↓ task · ←→ ribbon · tab pane · esc exit"); + return spread(tabs, keys, inner, this.palette); + } + + invalidate(): void { + this.cachedWidth = -1; + this.cachedHeight = -1; + this.cachedVersion = -1; + } + + dispose(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + if (this.streamFlushTimer) { + clearTimeout(this.streamFlushTimer); + this.streamFlushTimer = null; + } + liveStreamListener = null; + } +} + +const EMPTY_SET: ReadonlySet = new Set(); + +/** + * Convert raw terminal input (a typed key or a pasted block, possibly in + * bracketed-paste mode) into insertable single-line text: drop paste markers + * and ANSI escapes, fold tabs/newlines to spaces, strip other control bytes. + */ +function toInsertableText(data: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-byte stripping + const bracket = /\u001B\[20[01]~/g; + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-byte stripping + const ansi = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-byte stripping + const control = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; + return data + .replace(bracket, "") + .replace(ansi, "") + .replace(/[\r\n\t]/g, " ") + .replace(control, ""); +} + +function clipPad( + value: string, + width: number, + palette: DashboardPalette, +): string { + const clipped = palette.clip(value, width); + const pad = Math.max(0, width - palette.measure(clipped)); + return clipped + " ".repeat(pad); +} + +function spread( + left: string, + right: string, + width: number, + palette: DashboardPalette, +): string { + const lw = palette.measure(left); + const rw = palette.measure(right); + if (lw + rw + 1 > width) return clipPad(left, width, palette); + return left + " ".repeat(width - lw - rw) + right; +} + +function buildPalette(theme: Theme): DashboardPalette { + return { + text: (s) => theme.fg("text", s), + accent: (s) => theme.fg("accent", s), + muted: (s) => theme.fg("muted", s), + dim: (s) => theme.fg("dim", s), + success: (s) => theme.fg("success", s), + warning: (s) => theme.fg("warning", s), + error: (s) => theme.fg("error", s), + border: (s) => theme.fg("borderMuted", s), + bold: (s) => theme.bold(s), + inverse: (s) => theme.inverse(s), + stage: (stage, s) => theme.fg(STAGE_THEME_COLOR[stage], s), + measure: (s) => visibleWidth(s), + clip: (s, width) => truncateToWidth(s, Math.max(0, width), ""), + }; +} diff --git a/src/runtime/goal-tools.test.ts b/src/runtime/goal-tools.test.ts index 151cb6f..9ff4271 100644 --- a/src/runtime/goal-tools.test.ts +++ b/src/runtime/goal-tools.test.ts @@ -133,6 +133,31 @@ describe("planner goal tools", () => { ); }); + it("enters planning (not a second discovery) after approval in the improve flow", async () => { + const setup = await createGoalSetup({ + stage: "intake", + step: "await_goal_approval", + stepStatus: "running", + creationMethod: "improve", + }); + + const result = await executePlannerGoalTool({ + ...setup, + toolName: "planner_goal_decide", + params: { decision: "approve" }, + }); + + expect(result.status).toBe("applied"); + expect(result.text).toContain("planning"); + await expect( + readPlanState(setup.fs, setup.planPaths), + ).resolves.toMatchObject({ + stage: "planning", + step: "read_context", + stepStatus: "pending", + }); + }); + it("returns to goal drafting when the user requests a revision", async () => { const setup = await createGoalSetup({ stage: "intake", diff --git a/src/runtime/goal-tools.ts b/src/runtime/goal-tools.ts index b0707c4..f6fa6a0 100644 --- a/src/runtime/goal-tools.ts +++ b/src/runtime/goal-tools.ts @@ -113,19 +113,29 @@ export async function executePlannerGoalTool( ? "Goal approved by user." : `Goal revision requested by user.${feedback ? ` Feedback: ${feedback}` : ""}`, ); + // In the discovery-first improve flow, discovery already ran before the + // goal was drafted, so approval must continue into planning. The normal + // create flow drafts the goal first and only then enters discovery. + const improve = state.creationMethod === "improve"; + const approveNext = improve + ? { stage: "planning" as const, step: "read_context" as const } + : { stage: "discovery" as const, step: "scan_project_structure" as const }; const completed = completePlannerStep(state, { next: decision === "approve" - ? { stage: "discovery", step: "scan_project_structure" } + ? approveNext : { stage: "intake", step: "draft_goal" }, }); const next = advancePlannerStep(completed); const resumed = decision === "revise" ? startPlannerStep(next) : next; await savePlanState(input.fs, planPaths, resumed); + const approveMessage = improve + ? "Planner goal approved. Discovery already ran for this improve plan — planning is now available. Call planner_status, then continue planning/read_context." + : "Planner goal approved. Discovery is now available. Call planner_status, then start discovery/scan_project_structure."; return applied( input.toolName, decision === "approve" - ? "Planner goal approved. Discovery is now available. Call planner_status, then start discovery/scan_project_structure." + ? approveMessage : "Planner goal needs revision. Read request.md, apply the user's feedback, and call planner_goal_submit with the revised goal.md.", { decision, feedback, state: resumed }, ); diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index e343da9..5ff7afb 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -248,8 +248,7 @@ function stateMachineDecision( tool: "planner_finish_step", allowedTransitions, reason: `Planner step is running: ${state.stage}/${state.step}.`, - modelMessage: - "This compact boundary is disabled in state.json. Call planner_finish_step to skip the real Pi compact while preserving the state-machine checkpoint.", + modelMessage: `This compact boundary is disabled in settings. Do NOT call planner_request_compact. Call planner_finish_step with target ${formatNextTargets(state)} to skip the Pi compact while preserving the state-machine checkpoint.`, }); } const branchingTargets = getBranchingTargets(state); @@ -274,8 +273,7 @@ function stateMachineDecision( tool: "planner_finish_step", allowedTransitions, reason: `Planner step is running: ${state.stage}/${state.step}.`, - modelMessage: - "Finish the current step and start the next one in a single call: planner_finish_step. The response contains the next step name and instruction keys. Load those instruction files while waiting for the response, then call planner_status to verify the state.", + modelMessage: `Finish the current step and start the next one in a single call: planner_finish_step with target ${formatNextTargets(state)}. The response contains the next step name and instruction keys. Load those instruction files while waiting for the response, then call planner_status to verify the state.`, }); } case "completed": @@ -368,6 +366,25 @@ function baseDecision( }; } +function formatNextTargets(state: { + stage: PlannerStage; + step: PlannerStep; + creationMethod?: "create" | "improve"; +}): string { + try { + const next = getAllowedNextPlannerPositions({ + stage: state.stage, + step: state.step, + creationMethod: state.creationMethod, + }); + return next + .map((p) => `{stage: '${p.stage}', step: '${p.step}'}`) + .join(" or "); + } catch { + return ""; + } +} + function getBranchingTargets(state: { stage: PlannerStage; step: PlannerStep; diff --git a/src/runtime/orchestrator.ts b/src/runtime/orchestrator.ts index 7890149..7f6daae 100644 --- a/src/runtime/orchestrator.ts +++ b/src/runtime/orchestrator.ts @@ -265,9 +265,14 @@ function buildBlockedToolReason(input: { `Runtime action: ${input.orchestrator.preflight.decision.action}.`, `Lifecycle action: ${input.orchestrator.lifecycle.action}.`, `Next required action: ${formatNextAction(input.orchestrator.nextAction)}.`, + input.orchestrator.lifecycle.modelMessage + ? `Guidance: ${input.orchestrator.lifecycle.modelMessage}` + : "", `Allowed tools: ${input.orchestrator.allowedTools.join(", ") || "(none)"}.`, `Allowed transitions: ${input.orchestrator.allowedTransitions.join(", ") || "(none)"}.`, - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatNextAction(action: PlannerOrchestratorNextAction): string { diff --git a/src/runtime/state-machine.test.ts b/src/runtime/state-machine.test.ts index 1c962a2..fed2c6d 100644 --- a/src/runtime/state-machine.test.ts +++ b/src/runtime/state-machine.test.ts @@ -159,6 +159,45 @@ describe("planner state machine", () => { ); }); + it("lets run_final_tests go back to implement_task to fix a late failure", () => { + const current = state({ + stage: "execution", + step: "run_final_tests", + stepStatus: "running", + }); + expect(getAllowedNextPlannerPositions(current)).toEqual([ + { stage: "execution", step: "capture_skill" }, + { stage: "execution", step: "implement_task" }, + ] satisfies PlannerPosition[]); + expect( + completePlannerStep(current, { + next: { stage: "execution", step: "implement_task" }, + }), + ).toMatchObject({ stepStatus: "completed", nextStep: "implement_task" }); + }); + + it("advances compact_task linearly to select_next_task", () => { + const current = state({ + stage: "execution", + step: "compact_task", + stepStatus: "running", + }); + expect(getAllowedNextPlannerPositions(current)).toEqual([ + { stage: "execution", step: "select_next_task" }, + ] satisfies PlannerPosition[]); + expect( + completePlannerStep(current, { + next: { stage: "execution", step: "select_next_task" }, + }), + ).toMatchObject({ stepStatus: "completed", nextStep: "select_next_task" }); + // Finishing into the same step must be rejected (the old deadlock symptom). + expect(() => + completePlannerStep(current, { + next: { stage: "execution", step: "compact_task" }, + }), + ).toThrowStateMachine("invalid_next_step"); + }); + it("requires an explicit allowed branch after select_next_task", () => { const current = state({ stage: "execution", diff --git a/src/runtime/state-machine.ts b/src/runtime/state-machine.ts index 421a33f..72a74d3 100644 --- a/src/runtime/state-machine.ts +++ b/src/runtime/state-machine.ts @@ -116,10 +116,17 @@ export function getAllowedNextPlannerPositions( return [{ stage: "intake", step: "draft_goal" }]; } if (input.stage === "intake" && input.step === "await_goal_approval") { - return [ - { stage: "intake", step: "draft_goal" }, - { stage: "discovery", step: "scan_project_structure" }, - ]; + // The improve flow already ran discovery before drafting the goal, so + // approval continues into planning instead of repeating discovery. + return input.creationMethod === "improve" + ? [ + { stage: "intake", step: "draft_goal" }, + { stage: "planning", step: "read_context" }, + ] + : [ + { stage: "intake", step: "draft_goal" }, + { stage: "discovery", step: "scan_project_structure" }, + ]; } if (input.stage === "discovery" && input.step === "enter_planning") { return input.creationMethod === "improve" @@ -132,6 +139,14 @@ export function getAllowedNextPlannerPositions( if (input.stage === "planning" && input.step === "enter_execution") { return [{ stage: "execution", step: "prepare_task" }]; } + if (input.stage === "execution" && input.step === "run_final_tests") { + // Final checks may reveal a fix that needs an editing step. Allow going + // back to implement_task to fix + re-verify, not only forward. + return [ + { stage: "execution", step: "capture_skill" }, + { stage: "execution", step: "implement_task" }, + ]; + } if (input.stage === "execution" && input.step === "select_next_task") { return [ { stage: "execution", step: "prepare_task" }, diff --git a/src/runtime/status.ts b/src/runtime/status.ts index 780b559..4b88050 100644 --- a/src/runtime/status.ts +++ b/src/runtime/status.ts @@ -184,7 +184,7 @@ export const PLANNER_STEP_RULES = { ], exitCondition: "User explicitly approves the goal or requests a revision.", nextInstruction: - "Approve enters discovery/scan_project_structure. Revise returns to intake/draft_goal.", + "On approve: normal plans enter discovery/scan_project_structure; creationMethod=improve plans continue to planning/read_context (discovery already ran). Revise returns to intake/draft_goal.", }), scan_project_structure: stepRule("discovery", "scan_project_structure", { @@ -471,15 +471,17 @@ export const PLANNER_STEP_RULES = { objective: "Verify the completed task branch.", requiredActions: [ "Run final task checks and verify no accidental out-of-scope changes.", + "If a check fails and needs a code edit (this step cannot edit project files): call planner_finish_step with target {stage: 'execution', step: 'implement_task'} to fix it, then re-verify. Do NOT use planner_fail_step for this — fail/retry only re-runs the same step.", ], allowedNow: [ - "Run checks, inspect planner diff, and commit final fixes if needed.", + "Run checks and inspect the planner diff (no project edits here).", ], forbiddenNow: [ "Do not merge task to plan while tests fail or project files remain uncommitted.", ], exitCondition: "Final checks pass and the worktree is clean.", - nextInstruction: "Call planner_finish_step to open capture_skill.", + nextInstruction: + "On success: planner_finish_step with target execution/capture_skill. On a fix that needs edits: planner_finish_step with target execution/implement_task.", }), capture_skill: stepRule("execution", "capture_skill", { objective: @@ -519,16 +521,20 @@ export const PLANNER_STEP_RULES = { }), compact_task: stepRule("execution", "compact_task", { objective: - "Compact the completed task boundary and verify no hidden connections were missed.", + "Close the completed task boundary and verify no hidden connections were missed.", requiredActions: [ - "Before requesting compact: briefly check — did the task change any component that is called, imported, or depended upon by code outside the task scope? If yes and this was not captured in tdd.md or AGENTS.md, add a note to tdd.md before compacting.", - "Request Pi compact preserving task result, checks, artifacts, and next-task context.", + "Briefly check — did the task change any component called, imported, or depended upon by code outside the task scope? If yes and it was not captured in tdd.md or AGENTS.md, add a note to tdd.md.", + "If task compaction is ENABLED: call planner_request_compact, then planner_complete_compact after the boundary finishes.", + "If task compaction is DISABLED (the default): do NOT call planner_request_compact — call planner_finish_step with target {stage: 'execution', step: 'select_next_task'} to skip the Pi compact and keep the checkpoint.", + ], + allowedNow: [ + "Brief connection check, then the compact-or-finish flow only.", ], - allowedNow: ["Brief connection check, then compact flow only."], forbiddenNow: ["Do not edit task code while compact is required/pending."], exitCondition: - "Compaction finished and resume context points back to planner_status.", - nextInstruction: "Complete compact to open select_next_task.", + "Compaction finished, or the disabled boundary was skipped via finish_step. Either way state points to select_next_task.", + nextInstruction: + "Follow planner_status: enabled → request_compact then complete_compact; disabled → planner_finish_step with target execution/select_next_task.", }), select_next_task: stepRule("execution", "select_next_task", { objective: "Select the next task or finish execution.", diff --git a/src/runtime/timer.test.ts b/src/runtime/timer.test.ts index 5d4a097..f593019 100644 --- a/src/runtime/timer.test.ts +++ b/src/runtime/timer.test.ts @@ -87,27 +87,28 @@ describe("planner timer", () => { expect(stillPaused.displayActiveMs).toBe(5 * 60 * 1000); }); - it("keeps intake draft work paused before the goal is approved", () => { - const initialized = reconcilePlannerTimer({ - state: state({ - stage: "intake", - step: "draft_goal", - }), - planStatus: "active", - settings, - now: 0, - }).state; - - const later = reconcilePlannerTimer({ - state: initialized, - planStatus: "active", - settings, - now: 5 * 60 * 1000, - }); - - expect(later.status).toBe("paused"); - expect(later.state.timer?.activeMs).toBe(0); - expect(later.displayActiveMs).toBe(0); + it("counts active intake drafting and compaction (honest timing)", () => { + for (const position of [ + { stage: "intake" as const, step: "draft_goal" as const }, + { stage: "discovery" as const, step: "compact_discovery" as const }, + ]) { + const initialized = reconcilePlannerTimer({ + state: { ...state(position), requiresCompact: true }, + planStatus: "active", + settings, + now: 0, + }).state; + + const later = reconcilePlannerTimer({ + state: initialized, + planStatus: "active", + settings, + now: 5 * 60 * 1000, + }); + + expect(later.status).toBe("active"); + expect(later.displayActiveMs).toBeGreaterThan(0); + } }); it("resumes from pause without counting the paused gap", () => { diff --git a/src/runtime/timer.ts b/src/runtime/timer.ts index d0b0f24..1ce3a9b 100644 --- a/src/runtime/timer.ts +++ b/src/runtime/timer.ts @@ -281,9 +281,12 @@ function shouldPausePlannerTimer( planStatus: PlanStatus, ): boolean { if (shouldFinishPlannerTimer(state, planStatus)) return false; - if (state.stage === "intake") return true; + // Pause only while genuinely waiting on the user. Active work — including + // planner-controlled compaction — keeps counting for honest timing. if (state.requiresUserDecision) return true; - if (state.requiresCompact) return true; + if (state.stage === "intake" && state.step === "await_goal_approval") { + return true; + } if ( state.stage === "discovery" && state.step === "write_questions" && @@ -358,6 +361,7 @@ function buildTimerStatusLine(input: { ? pauseReason(input.state) : `${input.state.stage} stage ${formatDuration(currentStageActiveMs(input.state, input.displayActiveMs))}`, `${input.state.stage}/${input.state.step}`, + theme.fg("dim", "· /planner-dashboard"), ]; return parts.join(" "); } @@ -394,6 +398,7 @@ function buildTimerWidgetLines(input: { if (input.settings.showCheckpoints && checkpointTrail) { lines.push(`| ${padRight(`route ${checkpointTrail}`, width - 4)} |`); } + lines.push(`| ${padRight("/planner-dashboard for stats", width - 4)} |`); lines.push(`+${"-".repeat(width - 2)}+`); return lines; } diff --git a/src/settings/manager.ts b/src/settings/manager.ts index 38975d2..ea9bdb6 100644 --- a/src/settings/manager.ts +++ b/src/settings/manager.ts @@ -20,6 +20,7 @@ const DEFAULT_PLANNER_SETTINGS_FILE: PlannerSettingsFile = { timer: DEFAULT_PLANNER_SETTINGS.timer, skills: DEFAULT_PLANNER_SETTINGS.skills, contracts: DEFAULT_PLANNER_SETTINGS.contracts, + workspace: DEFAULT_PLANNER_SETTINGS.workspace, }; export interface EffectivePlannerSettings { @@ -34,6 +35,7 @@ export interface EffectivePlannerSettings { timerSource: "project" | "global" | "default"; skillsSource: "project" | "global" | "default"; contractsSource: "project" | "global" | "default"; + workspaceSource: "project" | "global" | "default"; } export async function loadEffectivePlannerSettings(input: { @@ -117,12 +119,31 @@ export async function loadEffectivePlannerSettings(input: { global.contracts, project?.contracts, ); + const workspaceSource = project?.workspace + ? "project" + : global.workspace + ? "global" + : "default"; + const workspace = { + ...DEFAULT_PLANNER_SETTINGS.workspace, + ...(global.workspace ?? {}), + ...(project?.workspace ?? {}), + }; return { paths, global, project, - effective: { worktree, compact, idle, metadata, timer, skills, contracts }, + effective: { + worktree, + compact, + idle, + metadata, + timer, + skills, + contracts, + workspace, + }, worktreeSource, compactSource, idleSource, @@ -130,6 +151,7 @@ export async function loadEffectivePlannerSettings(input: { timerSource, skillsSource, contractsSource, + workspaceSource, }; } @@ -173,9 +195,96 @@ function normalizeSettingsFile( ...(record.contracts === undefined ? {} : { contracts: normalizeContractsSettings(record.contracts, path) }), + ...(record.workspace === undefined + ? {} + : { workspace: normalizeWorkspaceSettings(record.workspace, path) }), }; } +function normalizeWorkspaceSettings( + value: unknown, + path: string, +): PlannerSettingsFile["workspace"] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError( + `Planner workspace settings must be an object: ${path}`, + ); + } + const record = value as Record; + const result: NonNullable = {}; + if (record.enabled !== undefined) { + if (typeof record.enabled !== "boolean") { + throw new TypeError( + `Planner workspace setting enabled must be boolean: ${path}`, + ); + } + result.enabled = record.enabled; + } + if (record.autoOpen !== undefined) { + if (typeof record.autoOpen !== "boolean") { + throw new TypeError( + `Planner workspace setting autoOpen must be boolean: ${path}`, + ); + } + result.autoOpen = record.autoOpen; + } + if (record.footerReserveRows !== undefined) { + if ( + typeof record.footerReserveRows !== "number" || + !Number.isInteger(record.footerReserveRows) || + record.footerReserveRows < 0 || + record.footerReserveRows > 20 + ) { + throw new TypeError( + `Planner workspace setting footerReserveRows must be an integer from 0 to 20: ${path}`, + ); + } + result.footerReserveRows = record.footerReserveRows; + } + if (record.keys !== undefined) { + result.keys = normalizeWorkspaceKeys(record.keys, path); + } + return result; +} + +const WORKSPACE_ACTIONS = [ + "focusNext", + "up", + "down", + "pageUp", + "pageDown", + "jumpBottom", + "jumpTop", + "expand", + "submit", + "exit", +] as const; + +function normalizeWorkspaceKeys( + value: unknown, + path: string, +): NonNullable["keys"] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`Planner workspace.keys must be an object: ${path}`); + } + const record = value as Record; + const result: Record = {}; + for (const action of WORKSPACE_ACTIONS) { + const keys = record[action]; + if (keys === undefined) continue; + if ( + !Array.isArray(keys) || + keys.some((k) => typeof k !== "string" || k.trim().length === 0) + ) { + throw new TypeError( + `Planner workspace.keys.${action} must be a non-empty string array: ${path}`, + ); + } + result[action] = keys.map((k) => (k as string).trim()); + } + return result; +} + function normalizeCompactSettings( value: unknown, path: string, diff --git a/src/settings/schema.ts b/src/settings/schema.ts index 3f557c9..e0a1da3 100644 --- a/src/settings/schema.ts +++ b/src/settings/schema.ts @@ -12,6 +12,7 @@ export interface PlannerSettings { timer: PlannerTimerSettings; skills: PlannerSkillsSettings; contracts: PlannerContractsSettings; + workspace: PlannerWorkspaceSettings; } export interface PlannerSettingsFile { @@ -22,8 +23,36 @@ export interface PlannerSettingsFile { timer?: Partial; skills?: Partial; contracts?: PlannerContractsSettingsFile; + workspace?: Partial; } +export interface PlannerWorkspaceSettings { + /** Master switch for the planner workspace TUI window. */ + enabled: boolean; + /** Open the workspace automatically for planner-worktree sessions. */ + autoOpen: boolean; + /** Terminal rows left for Pi's native footer below the workspace. */ + footerReserveRows: number; + /** Optional overrides for the workspace's own keybindings. */ + keys?: PlannerWorkspaceKeys; +} + +export type PlannerWorkspaceAction = + | "focusNext" + | "up" + | "down" + | "pageUp" + | "pageDown" + | "jumpBottom" + | "jumpTop" + | "expand" + | "submit" + | "exit"; + +export type PlannerWorkspaceKeys = Partial< + Record +>; + export interface PlannerIdleSettings { enabled: boolean; timeoutMinutes: number; @@ -118,4 +147,9 @@ export const DEFAULT_PLANNER_SETTINGS = { requireAfterTdd: true, requireBeforeEditOutsideChain: true, }, + workspace: { + enabled: true, + autoOpen: true, + footerReserveRows: 3, + }, } as const satisfies PlannerSettings; diff --git a/src/settings/settings.test.ts b/src/settings/settings.test.ts index 53d7f20..edcdda8 100644 --- a/src/settings/settings.test.ts +++ b/src/settings/settings.test.ts @@ -56,11 +56,16 @@ describe("planner settings", () => { requireAfterTdd: true, requireBeforeEditOutsideChain: true, }); + expect(settings.effective.workspace).toEqual({ + enabled: true, + autoOpen: true, + footerReserveRows: 3, + }); expect(settings.worktreeSource).toBe("global"); expect( fs.snapshot()["/agent/extensions/pi-code-planner/settings.json"], ).toBe( - '{\n "worktree": {\n "mode": "project-local"\n },\n "compact": {\n "stage": true,\n "task": false\n },\n "idle": {\n "enabled": true,\n "timeoutMinutes": 10\n },\n "metadata": {\n "humanLanguage": "English"\n },\n "timer": {\n "enabled": true,\n "mode": "status",\n "showCheckpoints": true,\n "maxCheckpoints": 5,\n "syncIntervalMinutes": 10\n },\n "skills": {\n "enabled": true,\n "maxActive": 0\n },\n "contracts": {\n "enabled": true,\n "finalPolicy": "ask",\n "scanBatchSize": 10,\n "statusCharBudget": 12000,\n "readChunkChars": 6000,\n "maxActiveChains": 3,\n "levelBudgets": {\n "root": 1800,\n "ancestor": 3000,\n "nearest": 7000\n },\n "requireAfterTdd": true,\n "requireBeforeEditOutsideChain": true\n }\n}\n', + '{\n "worktree": {\n "mode": "project-local"\n },\n "compact": {\n "stage": true,\n "task": false\n },\n "idle": {\n "enabled": true,\n "timeoutMinutes": 10\n },\n "metadata": {\n "humanLanguage": "English"\n },\n "timer": {\n "enabled": true,\n "mode": "status",\n "showCheckpoints": true,\n "maxCheckpoints": 5,\n "syncIntervalMinutes": 10\n },\n "skills": {\n "enabled": true,\n "maxActive": 0\n },\n "contracts": {\n "enabled": true,\n "finalPolicy": "ask",\n "scanBatchSize": 10,\n "statusCharBudget": 12000,\n "readChunkChars": 6000,\n "maxActiveChains": 3,\n "levelBudgets": {\n "root": 1800,\n "ancestor": 3000,\n "nearest": 7000\n },\n "requireAfterTdd": true,\n "requireBeforeEditOutsideChain": true\n },\n "workspace": {\n "enabled": true,\n "autoOpen": true,\n "footerReserveRows": 3\n }\n}\n', ); });