From da6f5d7b7aad7f80eb3f9e9137e8612d6ce2f8ab Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 07:49:29 +0500 Subject: [PATCH 01/14] feat: add planner dashboard TUI (/planner-dashboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-screen, theme-aware, adaptive dashboard for the planner: - Segmented stage progress ribbon with per-stage theme colors and in-segment labels, filling left-to-right as stages complete - Marquee ticker (route, active task, branch) scrollable with ←→ - Scrollable task list with status icons and selection follow - Detail panel with current step, branch, and per-stage timings derived from the timer checkpoints - Live refresh from disk while open; adapts to terminal resize; compact single-column layout on narrow terminals - Pure model + renderers in dashboard-model.ts (unit tested with an identity palette); interactive shell in dashboard.ts - Footer/widget hint pointing at /planner-dashboard - Add @earendil-works/pi-tui dependency for TUI primitives Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 59 +- package.json | 4 +- src/index.ts | 2 + src/runtime/dashboard-model.test.ts | 320 ++++++++++ src/runtime/dashboard-model.ts | 904 ++++++++++++++++++++++++++++ src/runtime/dashboard.ts | 272 +++++++++ src/runtime/timer.ts | 2 + 7 files changed, 1561 insertions(+), 2 deletions(-) create mode 100644 src/runtime/dashboard-model.test.ts create mode 100644 src/runtime/dashboard-model.ts create mode 100644 src/runtime/dashboard.ts 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..dc48e22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ import { PLANNER_CONTRACT_TOOL_NAMES, type PlannerContractToolName, } from "./runtime/contracts"; +import { registerPlannerDashboard } from "./runtime/dashboard"; import { DEBUG_INSTRUMENTATION_TYPES, DEBUG_PROBE_METHODS, @@ -1111,6 +1112,7 @@ export default function piCodePlannerExtension(pi: ExtensionAPI): void { registerPlannerTools(pi, compactRuntime); registerPlannerIdleWatchdog(pi, idleRuntime); registerPlannerRuntimeTimer(pi, timerRuntime); + registerPlannerDashboard(pi); registerPlannerBuiltinToolGuard(pi); registerPlannerCompactEvents(pi, compactRuntime); registerPlannerSkillResources(pi); diff --git a/src/runtime/dashboard-model.test.ts b/src/runtime/dashboard-model.test.ts new file mode 100644 index 0000000..43668b9 --- /dev/null +++ b/src/runtime/dashboard-model.test.ts @@ -0,0 +1,320 @@ +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 { + buildPlannerDashboardModel, + composeDashboard, + DASHBOARD_STAGE_SEQUENCE, + type DashboardModel, + type DashboardPalette, + type DashboardUiState, + renderStageRibbon, + renderTicker, +} 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, + tickerOffset: 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"); + }); +}); + +describe("renderTicker", () => { + it("returns a window of the exact width when content overflows", () => { + const model = buildPlannerDashboardModel({ + context: readyContext({ + stage: "execution", + step: "implement_task", + activeTaskId: "task-3", + currentBranch: "task/plan-a/task-3", + }), + now: 1000, + }) as DashboardModel; + const a = renderTicker(model, 30, 0, identityPalette); + const b = renderTicker(model, 30, 5, identityPalette); + expect(a.length).toBe(30); + expect(b.length).toBe(30); + expect(a).not.toBe(b); + }); +}); + +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..12e25fa --- /dev/null +++ b/src/runtime/dashboard-model.ts @@ -0,0 +1,904 @@ +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_CODE: Record = { + init: "INIT", + intake: "GOAL", + discovery: "DISC", + planning: "PLAN", + execution: "EXEC", + finalize: "FINL", + done: "DONE", + recovery: "RECV", +}; + +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; +} + +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; + tickerOffset: number; + focus: "tasks" | "ribbon"; +} + +export function buildPlannerDashboardModel(input: { + context: ActivePlanContext; + now: 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 totalActiveMs = state.timer?.activeMs ?? 0; + const timings = buildStageTimings(state, 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, + }; +} + +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"; +} + +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"; +} + +function buildStageTimings( + state: PlanStateRecord, + totalActiveMs: number, + currentStage: PlannerStage, +): DashboardStageTiming[] { + const checkpoints = state.timer?.checkpoints ?? []; + 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(state.stage, totalActiveMs); + } + const order: PlannerStage[] = [...DASHBOARD_STAGE_SEQUENCE]; + if (state.stage === "recovery") order.push("recovery"); + return order + .filter((stage) => perStage.has(stage)) + .map((stage) => ({ + stage, + activeMs: perStage.get(stage) ?? 0, + isCurrent: stage === currentStage || stage === state.stage, + })); +} + +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(renderTicker(model, inner, ui.tickerOffset, 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(renderTicker(model, inner, ui.tickerOffset, 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; +} + +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 stages = DASHBOARD_STAGE_SEQUENCE; + const count = stages.length; + // Allocate proportional segment widths that sum exactly to `width`. + const base = Math.floor(width / count); + let remainder = width - base * count; + const cells: string[] = []; + for (let i = 0; i < count; i++) { + let segWidth = base; + if (remainder > 0) { + segWidth += 1; + remainder -= 1; + } + cells.push(renderStageCell(model, stages[i], i, segWidth, palette)); + } + return [cells.join("")]; +} + +function renderStageCell( + model: DashboardModel, + stage: PlannerStage, + index: number, + segWidth: number, + palette: DashboardPalette, +): string { + if (segWidth <= 0) return ""; + const isDone = index < model.stageIndex; + const isCurrent = index === model.stageIndex && !model.recovery; + const fillRatio = isDone ? 1 : isCurrent ? model.stageRatio : 0; + const filledCount = Math.round(segWidth * fillRatio); + + const label = segWidth >= 6 ? STAGE_LABEL[stage] : STAGE_CODE[stage]; + const labelClipped = label.slice(0, Math.max(0, segWidth - 2)); + const labelStart = Math.max( + 0, + Math.floor((segWidth - labelClipped.length) / 2), + ); + + let out = ""; + for (let c = 0; c < segWidth; c++) { + const inLabel = c >= labelStart && c < labelStart + labelClipped.length; + const ch = inLabel + ? labelClipped[c - labelStart] + : c < filledCount + ? "▰" + : "░"; + out += paintCell({ + ch, + stage, + filled: c < filledCount, + isCurrent, + isDone, + palette, + }); + } + return out; +} + +function paintCell(input: { + ch: string; + stage: PlannerStage; + filled: boolean; + isCurrent: boolean; + isDone: boolean; + palette: DashboardPalette; +}): string { + const { ch, stage, filled, isCurrent, isDone, palette } = input; + if (isCurrent) { + const colored = palette.stage(stage, ch); + return filled ? palette.bold(colored) : colored; + } + if (isDone) return palette.stage(stage, ch); + return palette.dim(ch); +} + +export function renderTicker( + model: DashboardModel, + width: number, + offset: number, + palette: DashboardPalette, +): string { + const segments: string[] = []; + if (model.recovery) { + segments.push("RECOVERY MODE — resolve before resuming"); + } + segments.push(`${model.stage}/${model.step} (${model.stepStatus})`); + if (model.activeTaskId) { + const title = taskTitle(model, model.activeTaskId); + segments.push(`task ${model.activeTaskId}${title ? `: ${title}` : ""}`); + } + if (model.currentBranch) segments.push(`branch ${model.currentBranch}`); + if (model.note) segments.push(`note: ${model.note}`); + segments.push(`route ${model.routeTrail.join(" → ")}`); + + const gap = " • "; + const full = segments.join(gap) + gap; + const fullWidth = full.length; + let windowText: string; + if (fullWidth <= width) { + windowText = full; + } else { + const start = ((offset % fullWidth) + fullWidth) % fullWidth; + const doubled = full + full; + windowText = doubled.slice(start, start + width); + } + return padTo(palette.muted(windowText), width, palette); +} + +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)); + lines.push( + padTo(palette.warning(palette.clip(model.note, width)), width, palette), + ); + } + return fillColumn(lines, height, width, palette); +} + +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 +// --------------------------------------------------------------------------- + +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..f3f680d --- /dev/null +++ b/src/runtime/dashboard.ts @@ -0,0 +1,272 @@ +import { + type ExtensionAPI, + type ExtensionContext, + getAgentDir, + type Theme, + type ThemeColor, +} from "@earendil-works/pi-coding-agent"; +import { + type Component, + matchesKey, + type TUI, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; +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 { + buildPlannerDashboardModel, + composeDashboard, + type DashboardPalette, + type DashboardUiState, + type PlannerDashboardModel, +} from "./dashboard-model"; + +const MARQUEE_INTERVAL_MS = 180; +/** Reload the model from disk every Nth marquee tick (~1s). */ +const RELOAD_EVERY_TICKS = 6; +const STAGE_THEME_COLOR: Record = { + init: "syntaxComment", + intake: "syntaxKeyword", + discovery: "syntaxFunction", + planning: "syntaxType", + execution: "syntaxString", + finalize: "syntaxNumber", + done: "success", + recovery: "error", +}; + +export function registerPlannerDashboard(pi: ExtensionAPI): void { + const open = async (ctx: ExtensionContext) => { + if (!ctx.hasUI) { + ctx.ui.notify( + "The planner dashboard requires interactive mode.", + "error", + ); + return; + } + const fs = createNodeFs(); + const load = () => loadDashboardModel(fs, ctx.cwd); + const initial = await load(); + await ctx.ui.custom((tui, theme, _keybindings, done) => { + return new PlannerDashboardComponent({ + tui, + theme, + initial, + load, + onClose: () => done(undefined), + }); + }); + }; + + pi.registerCommand("planner-dashboard", { + description: + "Open the planner dashboard: live stage progress, task list, and session stats.", + handler: async (_args, ctx) => { + await open(ctx); + }, + }); + + pi.registerCommand("planner-stats", { + description: "Alias for /planner-dashboard.", + handler: async (_args, ctx) => { + await open(ctx); + }, + }); +} + +async function loadDashboardModel( + fs: PlannerFs, + cwd: string, +): Promise { + try { + const projectPaths: ProjectStoragePaths = await resolveProjectStoragePaths({ + fs, + agentDir: getAgentDir(), + cwd, + }); + const context = await readActivePlanContext({ fs, projectPaths }); + return buildPlannerDashboardModel({ context, now: Date.now() }); + } 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.", + }; + } +} + +class PlannerDashboardComponent implements Component { + private readonly tui: TUI; + private readonly theme: Theme; + private readonly palette: DashboardPalette; + private readonly load: () => Promise; + private readonly onClose: () => void; + private model: PlannerDashboardModel; + private readonly ui: DashboardUiState = { + selectedIndex: 0, + taskScroll: 0, + tickerOffset: 0, + focus: "tasks", + }; + private interval: ReturnType | null = null; + private tick = 0; + private reloading = false; + private version = 0; + private cachedWidth = -1; + private cachedHeight = -1; + private cachedVersion = -1; + private cachedLines: string[] = []; + + constructor(input: { + tui: TUI; + theme: Theme; + initial: PlannerDashboardModel; + load: () => Promise; + onClose: () => void; + }) { + this.tui = input.tui; + this.theme = input.theme; + this.load = input.load; + this.onClose = input.onClose; + this.model = input.initial; + this.palette = buildPalette(input.theme); + this.interval = setInterval(() => this.onTick(), MARQUEE_INTERVAL_MS); + this.interval.unref?.(); + } + + private onTick(): void { + this.ui.tickerOffset += 1; + this.version += 1; + this.tui.requestRender(); + if (this.tick % RELOAD_EVERY_TICKS === 0) void this.reload(); + this.tick += 1; + } + + private async reload(): Promise { + if (this.reloading) return; + this.reloading = true; + try { + this.model = await this.load(); + this.clampSelection(); + this.version += 1; + this.tui.requestRender(); + } catch { + // Best-effort live refresh; keep the last good model on failure. + } finally { + this.reloading = false; + } + } + + 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); + } + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || data === "q" || data === "Q") { + this.dispose(); + this.onClose(); + return; + } + if (data === "r" || data === "R") { + void this.reload(); + return; + } + if (matchesKey(data, "tab")) { + this.ui.focus = this.ui.focus === "tasks" ? "ribbon" : "tasks"; + this.bump(); + return; + } + if (matchesKey(data, "up")) { + this.ui.selectedIndex = Math.max(0, this.ui.selectedIndex - 1); + this.bump(); + return; + } + if (matchesKey(data, "down")) { + const total = this.model.available ? this.model.tasks.length : 0; + this.ui.selectedIndex = Math.min( + Math.max(0, total - 1), + this.ui.selectedIndex + 1, + ); + this.bump(); + return; + } + if (matchesKey(data, "left")) { + this.ui.tickerOffset -= 4; + this.bump(); + return; + } + if (matchesKey(data, "right")) { + this.ui.tickerOffset += 4; + this.bump(); + return; + } + } + + private bump(): void { + this.version += 1; + this.tui.requestRender(); + } + + render(width: number): string[] { + const height = Math.max(16, this.tui.terminal.rows - 1); + if ( + width === this.cachedWidth && + height === this.cachedHeight && + this.version === this.cachedVersion + ) { + return this.cachedLines; + } + const lines = composeDashboard({ + model: this.model, + width, + height, + palette: this.palette, + ui: this.ui, + }); + this.cachedWidth = width; + this.cachedHeight = height; + this.cachedVersion = this.version; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = -1; + this.cachedHeight = -1; + this.cachedVersion = -1; + } + + dispose(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +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/timer.ts b/src/runtime/timer.ts index d0b0f24..b2ac144 100644 --- a/src/runtime/timer.ts +++ b/src/runtime/timer.ts @@ -358,6 +358,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 +395,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; } From f772d8ea65c36bcb45b786c68a8154790a44d690 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 07:56:47 +0500 Subject: [PATCH 02/14] fix: pad stage ribbon labels so fill glyphs do not overlap text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve a blank cell on each side of each stage label and render the label with a readable treatment (bold/text/muted) instead of the stage fill color, so "INIT", "DISCOVERY", etc. stay legible against the ▰/░ progress fill. Co-Authored-By: Claude Opus 4.8 --- src/runtime/dashboard-model.ts | 39 +++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/runtime/dashboard-model.ts b/src/runtime/dashboard-model.ts index 12e25fa..674f589 100644 --- a/src/runtime/dashboard-model.ts +++ b/src/runtime/dashboard-model.ts @@ -507,21 +507,23 @@ function renderStageCell( const fillRatio = isDone ? 1 : isCurrent ? model.stageRatio : 0; const filledCount = Math.round(segWidth * fillRatio); - const label = segWidth >= 6 ? STAGE_LABEL[stage] : STAGE_CODE[stage]; - const labelClipped = label.slice(0, Math.max(0, segWidth - 2)); - const labelStart = Math.max( - 0, - Math.floor((segWidth - labelClipped.length) / 2), - ); + // Reserve a label band with a blank cell on each side so the fill glyphs + // never butt up against the letters (keeps the labels readable). + const maxLabel = Math.max(0, segWidth - 4); + const rawLabel = segWidth >= 8 ? STAGE_LABEL[stage] : STAGE_CODE[stage]; + const labelText = rawLabel.slice(0, maxLabel); + const band = labelText ? ` ${labelText} ` : ""; + const bandStart = Math.max(0, Math.floor((segWidth - band.length) / 2)); + const bandEnd = bandStart + band.length; let out = ""; for (let c = 0; c < segWidth; c++) { - const inLabel = c >= labelStart && c < labelStart + labelClipped.length; - const ch = inLabel - ? labelClipped[c - labelStart] - : c < filledCount - ? "▰" - : "░"; + if (band && c >= bandStart && c < bandEnd) { + const ch = band[c - bandStart]; + out += paintLabel({ ch, isCurrent, isDone, palette }); + continue; + } + const ch = c < filledCount ? "▰" : "░"; out += paintCell({ ch, stage, @@ -534,6 +536,19 @@ function renderStageCell( return out; } +function paintLabel(input: { + ch: string; + isCurrent: boolean; + isDone: boolean; + palette: DashboardPalette; +}): string { + const { ch, isCurrent, isDone, palette } = input; + if (ch === " ") return ch; + if (isCurrent) return palette.bold(palette.text(ch)); + if (isDone) return palette.text(ch); + return palette.muted(ch); +} + function paintCell(input: { ch: string; stage: PlannerStage; From 60c12b50225ed48c5dafd69566515035c0db4e1c Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 11:46:32 +0500 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20planner=20workspace=20=E2=80=94?= =?UTF-8?q?=20dashboard=20+=20live=20model=20chat=20in=20one=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild /planner-dashboard as a full-screen workspace (Option 3): - Top band: stage ribbon + status + marquee ticker - Middle: live model conversation rendered from session entries (projectSessionEntries + renderTranscript), planner tool calls and results collapsed to one line by default, expandable - Bottom: our own input line that sends to the model via pi.sendUserMessage, plus focus tabs (input / chat / tasks) - Tab cycles panes; tasks pane expands the task list + stage timings; chat pane scrolls and toggles expand-all; live refresh while open - Pure transcript module (chat-view.ts) unit tested with identity palette Co-Authored-By: Claude Opus 4.8 --- src/runtime/chat-view.test.ts | 183 +++++++++++++++ src/runtime/chat-view.ts | 405 +++++++++++++++++++++++++++++++++ src/runtime/dashboard-model.ts | 73 ++++++ src/runtime/dashboard.ts | 371 +++++++++++++++++++++++++----- 4 files changed, 972 insertions(+), 60 deletions(-) create mode 100644 src/runtime/chat-view.test.ts create mode 100644 src/runtime/chat-view.ts diff --git a/src/runtime/chat-view.test.ts b/src/runtime/chat-view.test.ts new file mode 100644 index 0000000..29f6cf5 --- /dev/null +++ b/src/runtime/chat-view.test.ts @@ -0,0 +1,183 @@ +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, + scrollFromBottom: 0, + expanded: new Set(), + focused: false, + ...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("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("scrolls up from the bottom", () => { + const many: ChatRow[] = Array.from({ length: 40 }, (_, i) => ({ + role: "user" as const, + text: `message ${i}`, + collapsible: false, + key: `m${i}`, + })); + const bottom = renderTranscript(many, baseOptions(), palette); + const scrolled = renderTranscript( + many, + baseOptions({ scrollFromBottom: 5 }), + palette, + ); + expect(bottom.lines.join("\n")).not.toBe(scrolled.lines.join("\n")); + expect(bottom.totalLines).toBe(40); + }); +}); diff --git a/src/runtime/chat-view.ts b/src/runtime/chat-view.ts new file mode 100644 index 0000000..c4615ef --- /dev/null +++ b/src/runtime/chat-view.ts @@ -0,0 +1,405 @@ +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; +} + +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; + /** Lines scrolled up from the bottom (0 = pinned to newest). */ + scrollFromBottom: number; + expanded: ReadonlySet; + focused: boolean; +} + +export interface TranscriptResult { + lines: string[]; + /** Total renderable lines (for scroll clamping by the caller). */ + totalLines: 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 maxScroll = Math.max(0, total - height); + const scroll = Math.min(Math.max(0, options.scrollFromBottom), maxScroll); + const end = total - scroll; + const start = Math.max(0, end - height); + const window = all.slice(start, end); + while (window.length < height) window.push(""); + return { + lines: window.map((line) => clipPad(line, width, palette)), + totalLines: total, + }; +} + +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 text.replace(/\r/g, "").split("\n"); +} + +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.ts b/src/runtime/dashboard-model.ts index 674f589..6d48694 100644 --- a/src/runtime/dashboard-model.ts +++ b/src/runtime/dashboard-model.ts @@ -456,6 +456,59 @@ function renderCompactBody(input: BodyInput): string[] { 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, + tickerOffset: number, + palette: DashboardPalette, +): string[] { + if (!model.available) { + return [padTo(palette.warning(model.reason), inner, palette)]; + } + return [ + renderHeaderLine(model, inner, palette), + ...renderStageRibbon(model, inner, palette), + renderTicker(model, inner, tickerOffset, 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, @@ -787,6 +840,26 @@ function statusBadge( // 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; diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index f3f680d..c411c1a 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -17,17 +17,27 @@ 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, + projectSessionEntries, + renderTranscript, +} from "./chat-view"; import { buildPlannerDashboardModel, - composeDashboard, type DashboardPalette, type DashboardUiState, + dashboardDivider, + formatClock, + frameWorkspace, type PlannerDashboardModel, + renderDashboardBand, + renderDashboardColumns, } from "./dashboard-model"; -const MARQUEE_INTERVAL_MS = 180; -/** Reload the model from disk every Nth marquee tick (~1s). */ +const TICK_MS = 180; +/** Reload the model from disk every Nth tick (~1s). */ const RELOAD_EVERY_TICKS = 6; + const STAGE_THEME_COLOR: Record = { init: "syntaxComment", intake: "syntaxKeyword", @@ -40,41 +50,38 @@ const STAGE_THEME_COLOR: Record = { }; export function registerPlannerDashboard(pi: ExtensionAPI): void { - const open = async (ctx: ExtensionContext) => { - if (!ctx.hasUI) { - ctx.ui.notify( - "The planner dashboard requires interactive mode.", - "error", - ); - return; - } - const fs = createNodeFs(); - const load = () => loadDashboardModel(fs, ctx.cwd); - const initial = await load(); - await ctx.ui.custom((tui, theme, _keybindings, done) => { - return new PlannerDashboardComponent({ - tui, - theme, - initial, - load, - onClose: () => done(undefined), - }); - }); - }; - pi.registerCommand("planner-dashboard", { description: - "Open the planner dashboard: live stage progress, task list, and session stats.", + "Open the planner workspace: live stage dashboard, task list, and the model chat in one window.", handler: async (_args, ctx) => { - await open(ctx); + await openPlannerWorkspace(pi, ctx); }, }); +} - pi.registerCommand("planner-stats", { - description: "Alias for /planner-dashboard.", - handler: async (_args, ctx) => { - await open(ctx); - }, +export async function openPlannerWorkspace( + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + if (!ctx.hasUI) { + ctx.ui.notify("The planner workspace requires interactive mode.", "error"); + return; + } + const fs = createNodeFs(); + const load = () => loadDashboardModel(fs, ctx.cwd); + const getRows = () => projectSessionEntries(ctx.sessionManager.getBranch()); + const initial = await load(); + await ctx.ui.custom((tui, theme, _keybindings, done) => { + return new PlannerWorkspaceComponent({ + tui, + theme, + initial, + initialRows: getRows(), + load, + getRows, + sendUserMessage: (text) => pi.sendUserMessage(text), + onClose: () => done(undefined), + }); }); } @@ -101,23 +108,36 @@ async function loadDashboardModel( } } -class PlannerDashboardComponent implements Component { +type WorkspaceFocus = "input" | "chat" | "tasks"; + +class PlannerWorkspaceComponent implements Component { private readonly tui: TUI; - private readonly theme: Theme; private readonly palette: DashboardPalette; private readonly load: () => Promise; + private readonly getRows: () => ChatRow[]; + private readonly sendUserMessage: (text: string) => void; private readonly onClose: () => void; + private model: PlannerDashboardModel; + private rows: ChatRow[]; + private input = ""; + private cursor = 0; + private focus: WorkspaceFocus = "input"; + private chatScroll = 0; + private expandAll = false; private readonly ui: DashboardUiState = { selectedIndex: 0, taskScroll: 0, tickerOffset: 0, focus: "tasks", }; + private interval: ReturnType | null = null; private tick = 0; private reloading = false; private version = 0; + private lastTranscriptTotal = 0; + private lastTranscriptHeight = 1; private cachedWidth = -1; private cachedHeight = -1; private cachedVersion = -1; @@ -127,28 +147,42 @@ class PlannerDashboardComponent implements Component { tui: TUI; theme: Theme; initial: PlannerDashboardModel; + initialRows: ChatRow[]; load: () => Promise; + getRows: () => ChatRow[]; + sendUserMessage: (text: string) => void; onClose: () => void; }) { this.tui = input.tui; - this.theme = input.theme; + this.palette = buildPalette(input.theme); this.load = input.load; + this.getRows = input.getRows; + this.sendUserMessage = input.sendUserMessage; this.onClose = input.onClose; this.model = input.initial; - this.palette = buildPalette(input.theme); - this.interval = setInterval(() => this.onTick(), MARQUEE_INTERVAL_MS); + this.rows = input.initialRows; + this.interval = setInterval(() => this.onTick(), TICK_MS); this.interval.unref?.(); } private onTick(): void { this.ui.tickerOffset += 1; + this.refreshRows(); this.version += 1; this.tui.requestRender(); - if (this.tick % RELOAD_EVERY_TICKS === 0) void this.reload(); + if (this.tick % RELOAD_EVERY_TICKS === 0) void this.reloadModel(); this.tick += 1; } - private async reload(): Promise { + private refreshRows(): void { + try { + this.rows = this.getRows(); + } catch { + // Keep last rows on transient read failure. + } + } + + private async reloadModel(): Promise { if (this.reloading) return; this.reloading = true; try { @@ -157,7 +191,7 @@ class PlannerDashboardComponent implements Component { this.version += 1; this.tui.requestRender(); } catch { - // Best-effort live refresh; keep the last good model on failure. + // Best-effort. } finally { this.reloading = false; } @@ -171,46 +205,127 @@ class PlannerDashboardComponent implements Component { } handleInput(data: string): void { - if (matchesKey(data, "escape") || data === "q" || data === "Q") { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.dispose(); this.onClose(); return; } - if (data === "r" || data === "R") { - void this.reload(); + if (matchesKey(data, "tab")) { + this.cycleFocus(); + return; + } + if (this.focus === "input") { + this.handleInputFocus(data); return; } - if (matchesKey(data, "tab")) { - this.ui.focus = this.ui.focus === "tasks" ? "ribbon" : "tasks"; + 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 (matchesKey(data, "enter")) { + 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, "up")) { - this.ui.selectedIndex = Math.max(0, this.ui.selectedIndex - 1); + if (matchesKey(data, "right")) { + this.cursor = Math.min(this.input.length, this.cursor + 1); this.bump(); return; } - if (matchesKey(data, "down")) { - const total = this.model.available ? this.model.tasks.length : 0; + if (isPrintable(data)) { + this.input = + this.input.slice(0, this.cursor) + data + this.input.slice(this.cursor); + this.cursor += data.length; + this.bump(); + } + } + + private handleChatFocus(data: string): void { + const page = Math.max(1, this.lastTranscriptHeight - 1); + if (matchesKey(data, "up")) { + this.scrollChat(1); + } else if (matchesKey(data, "down")) { + this.scrollChat(-1); + } else if (matchesKey(data, "pageUp")) { + this.scrollChat(page); + } else if (matchesKey(data, "pageDown")) { + this.scrollChat(-page); + } else if (data === "x" || data === "X") { + this.expandAll = !this.expandAll; + this.bump(); + } + } + + private handleTasksFocus(data: string): void { + const total = this.model.available ? this.model.tasks.length : 0; + if (matchesKey(data, "up")) { + this.ui.selectedIndex = Math.max(0, this.ui.selectedIndex - 1); + this.bump(); + } else if (matchesKey(data, "down")) { this.ui.selectedIndex = Math.min( Math.max(0, total - 1), this.ui.selectedIndex + 1, ); this.bump(); - return; - } - if (matchesKey(data, "left")) { + } else if (matchesKey(data, "left")) { this.ui.tickerOffset -= 4; this.bump(); - return; - } - if (matchesKey(data, "right")) { + } else if (matchesKey(data, "right")) { this.ui.tickerOffset += 4; this.bump(); - return; } } + private scrollChat(delta: number): void { + const maxScroll = Math.max( + 0, + this.lastTranscriptTotal - this.lastTranscriptHeight, + ); + this.chatScroll = Math.min(maxScroll, Math.max(0, this.chatScroll + delta)); + this.bump(); + } + + private submit(): void { + const text = this.input.trim(); + if (!text) return; + this.input = ""; + this.cursor = 0; + this.chatScroll = 0; + try { + this.sendUserMessage(text); + } catch { + // Ignore send failures; the next refresh will reflect agent state. + } + this.bump(); + } + private bump(): void { this.version += 1; this.tui.requestRender(); @@ -225,12 +340,68 @@ class PlannerDashboardComponent implements Component { ) { return this.cachedLines; } - const lines = composeDashboard({ - model: this.model, + + const model = this.model; + const inner = width - 2; + const bodyHeight = Math.max(1, height - 2); + const band = renderDashboardBand( + model, + inner, + this.ui.tickerOffset, + 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( + this.rows, + { + width: inner, + height: transcriptHeight, + scrollFromBottom: this.chatScroll, + expanded: this.expandedKeys(), + focused: this.focus === "chat", + }, + this.palette, + ); + this.lastTranscriptTotal = transcript.totalLines; + this.lastTranscriptHeight = transcriptHeight; + + const body = [...top, ...transcript.lines, ...bottom]; + const lines = frameWorkspace({ + palette: this.palette, width, height, - palette: this.palette, - ui: this.ui, + title: this.title(), + clock: this.model.available ? formatClock(this.model.totalActiveMs) : "", + body, }); this.cachedWidth = width; this.cachedHeight = height; @@ -239,6 +410,53 @@ class PlannerDashboardComponent implements Component { 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 · tab pane · esc exit") + : this.focus === "chat" + ? this.palette.dim("↑↓ scroll · x expand · tab pane · esc exit") + : this.palette.dim("↑↓ task · ←→ ribbon · tab pane · esc exit"); + return spread(tabs, keys, inner, this.palette); + } + invalidate(): void { this.cachedWidth = -1; this.cachedHeight = -1; @@ -253,6 +471,39 @@ class PlannerDashboardComponent implements Component { } } +const EMPTY_SET: ReadonlySet = new Set(); + +function isPrintable(data: string): boolean { + if (data.length === 0) return false; + for (let i = 0; i < data.length; i++) { + const code = data.charCodeAt(i); + if (code < 32 || code === 127) return false; + } + return true; +} + +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), From a2035a7a1bce8d7a3d1ad57a79fa9f8de087b08d Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 11:49:48 +0500 Subject: [PATCH 04/14] feat: auto-open planner workspace, remove /planner-preview - Auto-open the workspace once per session for planner-worktree sessions via a session_start hook (covers create/resume/improve without touching the fragile session-switch handoff). Reopen with /planner-dashboard; close to fall back to the plain chat. - Remove /planner-preview (the workspace replaces it). - README: document /planner-dashboard, drop /planner-preview. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++--- src/index.ts | 89 ++++++++++++++++++++++------------------------------ 2 files changed, 41 insertions(+), 56 deletions(-) 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/src/index.ts b/src/index.ts index dc48e22..eff6b47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,10 @@ import { PLANNER_CONTRACT_TOOL_NAMES, type PlannerContractToolName, } from "./runtime/contracts"; -import { registerPlannerDashboard } from "./runtime/dashboard"; +import { + openPlannerWorkspace, + registerPlannerDashboard, +} from "./runtime/dashboard"; import { DEBUG_INSTRUMENTATION_TYPES, DEBUG_PROBE_METHODS, @@ -1113,6 +1116,7 @@ export default function piCodePlannerExtension(pi: ExtensionAPI): void { registerPlannerIdleWatchdog(pi, idleRuntime); registerPlannerRuntimeTimer(pi, timerRuntime); registerPlannerDashboard(pi); + registerPlannerWorkspaceAutoOpen(pi); registerPlannerBuiltinToolGuard(pi); registerPlannerCompactEvents(pi, compactRuntime); registerPlannerSkillResources(pi); @@ -1120,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); + } catch { + // Best-effort: never block session start on the workspace. + } + }); +} + function registerPlannerSkillResources(pi: ExtensionAPI): void { pi.on("resources_discover", async (event) => { const fs = createNodeFs(); @@ -1938,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.", From f603cc2553f64b221445adacf9d5e38f904f9851 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 12:09:35 +0500 Subject: [PATCH 05/14] feat: workspace overlay mode + configurable workspace settings Fixes the footer overlap and mouse-wheel-into-scrollback issues, and makes the TUI configurable: - Render the workspace as a fixed top overlay (overlay: true) instead of an inline scrollback component, so the mouse wheel no longer drags it off-screen and the native footer stays visible below it. - Size the overlay to terminal rows minus a configurable footer reserve. - Add a `workspace` settings group: enabled, autoOpen, footerReserveRows (schema, defaults, manager merge/normalize, /planner-helper docs). - openPlannerWorkspace loads settings; auto-open respects autoOpen and the master enabled switch; footer reserve is tunable if the footer overlaps or leaves a gap. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 2 +- src/runtime/about.ts | 15 ++++++ src/runtime/dashboard.ts | 91 ++++++++++++++++++++++++++++++----- src/settings/manager.ts | 70 ++++++++++++++++++++++++++- src/settings/schema.ts | 16 ++++++ src/settings/settings.test.ts | 7 ++- 6 files changed, 185 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index eff6b47..1009def 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1149,7 +1149,7 @@ function registerPlannerWorkspaceAutoOpen(pi: ExtensionAPI): void { const worktreePath = context.state.worktreePath; if (!worktreePath || !isPathInsideOrEqual(ctx.cwd, worktreePath)) return; openedSessions.add(sessionId); - void openPlannerWorkspace(pi, ctx); + void openPlannerWorkspace(pi, ctx, { auto: true }); } catch { // Best-effort: never block session start on the workspace. } diff --git a/src/runtime/about.ts b/src/runtime/about.ts index be0e4dd..b70c59e 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.", diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index c411c1a..8304ece 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -12,6 +12,7 @@ import { 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"; @@ -37,6 +38,8 @@ import { const TICK_MS = 180; /** Reload the model from disk every Nth tick (~1s). */ const RELOAD_EVERY_TICKS = 6; +/** Rows left for Pi's native footer below the workspace overlay. */ +const DEFAULT_FOOTER_RESERVE = 3; const STAGE_THEME_COLOR: Record = { init: "syntaxComment", @@ -62,27 +65,86 @@ export function registerPlannerDashboard(pi: ExtensionAPI): void { export async function openPlannerWorkspace( pi: ExtensionAPI, ctx: ExtensionContext, + options: { auto?: boolean } = {}, ): Promise { if (!ctx.hasUI) { - ctx.ui.notify("The planner workspace requires interactive mode.", "error"); + if (!options.auto) { + ctx.ui.notify( + "The planner workspace requires interactive mode.", + "error", + ); + } return; } const fs = createNodeFs(); + const workspace = await loadWorkspaceSettings(fs, ctx.cwd); + if (!workspace.enabled) { + if (!options.auto) { + ctx.ui.notify( + "The planner workspace is disabled (settings: workspace.enabled).", + "info", + ); + } + return; + } + if (options.auto && !workspace.autoOpen) return; + const footerReserve = Math.max(0, workspace.footerReserveRows); const load = () => loadDashboardModel(fs, ctx.cwd); const getRows = () => projectSessionEntries(ctx.sessionManager.getBranch()); const initial = await load(); - await ctx.ui.custom((tui, theme, _keybindings, done) => { - return new PlannerWorkspaceComponent({ - tui, - theme, - initial, - initialRows: getRows(), - load, - getRows, - sendUserMessage: (text) => pi.sendUserMessage(text), - onClose: () => done(undefined), + await ctx.ui.custom( + (tui, theme, _keybindings, done) => { + return new PlannerWorkspaceComponent({ + tui, + theme, + initial, + initialRows: getRows(), + footerReserve, + load, + getRows, + 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 }> { + try { + const projectPaths = await resolveProjectStoragePaths({ + fs, + agentDir: getAgentDir(), + cwd, }); - }); + const settings = await loadEffectivePlannerSettings({ fs, projectPaths }); + return settings.effective.workspace; + } catch { + return { + enabled: true, + autoOpen: true, + footerReserveRows: DEFAULT_FOOTER_RESERVE, + }; + } } async function loadDashboardModel( @@ -117,6 +179,7 @@ class PlannerWorkspaceComponent implements Component { private readonly getRows: () => ChatRow[]; private readonly sendUserMessage: (text: string) => void; private readonly onClose: () => void; + private readonly footerReserve: number; private model: PlannerDashboardModel; private rows: ChatRow[]; @@ -148,6 +211,7 @@ class PlannerWorkspaceComponent implements Component { theme: Theme; initial: PlannerDashboardModel; initialRows: ChatRow[]; + footerReserve: number; load: () => Promise; getRows: () => ChatRow[]; sendUserMessage: (text: string) => void; @@ -155,6 +219,7 @@ class PlannerWorkspaceComponent implements Component { }) { this.tui = input.tui; this.palette = buildPalette(input.theme); + this.footerReserve = input.footerReserve; this.load = input.load; this.getRows = input.getRows; this.sendUserMessage = input.sendUserMessage; @@ -332,7 +397,7 @@ class PlannerWorkspaceComponent implements Component { } render(width: number): string[] { - const height = Math.max(16, this.tui.terminal.rows - 1); + const height = Math.max(16, this.tui.terminal.rows - this.footerReserve); if ( width === this.cachedWidth && height === this.cachedHeight && diff --git a/src/settings/manager.ts b/src/settings/manager.ts index 38975d2..fa61882 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,55 @@ 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; + } + return result; +} + function normalizeCompactSettings( value: unknown, path: string, diff --git a/src/settings/schema.ts b/src/settings/schema.ts index 3f557c9..37d489a 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,6 +23,16 @@ 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; } export interface PlannerIdleSettings { @@ -118,4 +129,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', ); }); From 1a60d136c2d2ce60340836b713e03edfb41df816 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 12:28:59 +0500 Subject: [PATCH 06/14] feat: live streaming, bracket ribbon, calmer redraw, keybinding docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stream assistant output live: subscribe to message_update/message_end and append the in-flight assistant message to the transcript, so tokens appear as they are generated instead of only after the turn commits. - Replace the glyph-fill stage ribbon with a clean bracket style (INIT › INTAKE › [DISCOVERY] › …): active stage bracketed + bold in its theme colour, completed stages coloured, pending dimmed. Adapts to width. - Redraw only when something visible changed (content, clock second, coarse marquee step, focus/input) instead of every tick — removes the choppy repaint and steadies the ticker. - Document the workspace keys, settings, and Pi keybindings.json in the /planner-helper report and SETTINGS.md. Co-Authored-By: Claude Opus 4.8 --- SETTINGS.md | 31 ++++++++++ src/runtime/about.ts | 13 +++- src/runtime/chat-view.ts | 11 ++++ src/runtime/dashboard-model.ts | 109 ++++----------------------------- src/runtime/dashboard.ts | 65 +++++++++++++++++--- 5 files changed, 125 insertions(+), 104 deletions(-) diff --git a/SETTINGS.md b/SETTINGS.md index c2ac9fb..18cf0ed 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,32 @@ 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, `Enter` to send to the model | +| chat | `↑`/`↓`, `PageUp`/`PageDown` scroll the transcript; `x` toggles expand-all for collapsed tool calls | +| tasks | `↑`/`↓` select a task and reveal the task list + stage timings; `←`/`→` nudge the ticker | + +`Esc` (or `Ctrl+C`) closes the workspace and returns to the plain chat. Streaming assistant output appears live. + +### 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/src/runtime/about.ts b/src/runtime/about.ts index b70c59e..f1909f5 100644 --- a/src/runtime/about.ts +++ b/src/runtime/about.ts @@ -196,6 +196,17 @@ 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 and press Enter to send a message to the model.", + " - chat: ↑↓ / PageUp / PageDown scroll the transcript; x toggles expand-all for collapsed tool calls.", + " - tasks: ↑↓ select a task and reveal the task list + stage timings; ←→ nudge the ticker.", + "- Esc (or Ctrl+C) closes the workspace and returns to the plain chat.", + "- Streaming assistant output is shown live 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.", "", @@ -207,7 +218,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.ts b/src/runtime/chat-view.ts index c4615ef..63a1db0 100644 --- a/src/runtime/chat-view.ts +++ b/src/runtime/chat-view.ts @@ -63,6 +63,17 @@ export function projectSessionEntries(entries: SessionEntry[]): ChatRow[] { 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") { diff --git a/src/runtime/dashboard-model.ts b/src/runtime/dashboard-model.ts index 6d48694..34474db 100644 --- a/src/runtime/dashboard-model.ts +++ b/src/runtime/dashboard-model.ts @@ -27,17 +27,6 @@ export const DASHBOARD_STAGE_SEQUENCE = [ "done", ] as const satisfies readonly PlannerStage[]; -const STAGE_CODE: Record = { - init: "INIT", - intake: "GOAL", - discovery: "DISC", - planning: "PLAN", - execution: "EXEC", - finalize: "FINL", - done: "DONE", - recovery: "RECV", -}; - const STAGE_LABEL: Record = { init: "INIT", intake: "INTAKE", @@ -530,93 +519,21 @@ export function renderStageRibbon( width: number, palette: DashboardPalette, ): string[] { - const stages = DASHBOARD_STAGE_SEQUENCE; - const count = stages.length; - // Allocate proportional segment widths that sum exactly to `width`. - const base = Math.floor(width / count); - let remainder = width - base * count; - const cells: string[] = []; - for (let i = 0; i < count; i++) { - let segWidth = base; - if (remainder > 0) { - segWidth += 1; - remainder -= 1; + const sep = palette.dim(" › "); + 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}]`)); } - cells.push(renderStageCell(model, stages[i], i, segWidth, palette)); - } - return [cells.join("")]; -} - -function renderStageCell( - model: DashboardModel, - stage: PlannerStage, - index: number, - segWidth: number, - palette: DashboardPalette, -): string { - if (segWidth <= 0) return ""; - const isDone = index < model.stageIndex; - const isCurrent = index === model.stageIndex && !model.recovery; - const fillRatio = isDone ? 1 : isCurrent ? model.stageRatio : 0; - const filledCount = Math.round(segWidth * fillRatio); - - // Reserve a label band with a blank cell on each side so the fill glyphs - // never butt up against the letters (keeps the labels readable). - const maxLabel = Math.max(0, segWidth - 4); - const rawLabel = segWidth >= 8 ? STAGE_LABEL[stage] : STAGE_CODE[stage]; - const labelText = rawLabel.slice(0, maxLabel); - const band = labelText ? ` ${labelText} ` : ""; - const bandStart = Math.max(0, Math.floor((segWidth - band.length) / 2)); - const bandEnd = bandStart + band.length; - - let out = ""; - for (let c = 0; c < segWidth; c++) { - if (band && c >= bandStart && c < bandEnd) { - const ch = band[c - bandStart]; - out += paintLabel({ ch, isCurrent, isDone, palette }); - continue; + if (index < model.stageIndex) { + // Completed stage: stage colour. + return palette.stage(stage, label); } - const ch = c < filledCount ? "▰" : "░"; - out += paintCell({ - ch, - stage, - filled: c < filledCount, - isCurrent, - isDone, - palette, - }); - } - return out; -} - -function paintLabel(input: { - ch: string; - isCurrent: boolean; - isDone: boolean; - palette: DashboardPalette; -}): string { - const { ch, isCurrent, isDone, palette } = input; - if (ch === " ") return ch; - if (isCurrent) return palette.bold(palette.text(ch)); - if (isDone) return palette.text(ch); - return palette.muted(ch); -} - -function paintCell(input: { - ch: string; - stage: PlannerStage; - filled: boolean; - isCurrent: boolean; - isDone: boolean; - palette: DashboardPalette; -}): string { - const { ch, stage, filled, isCurrent, isDone, palette } = input; - if (isCurrent) { - const colored = palette.stage(stage, ch); - return filled ? palette.bold(colored) : colored; - } - if (isDone) return palette.stage(stage, ch); - return palette.dim(ch); + // Pending stage: dimmed. + return palette.dim(label); + }); + return [padTo(parts.join(sep), width, palette)]; } export function renderTicker( diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index 8304ece..4429902 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -20,6 +20,7 @@ import type { PlannerStage } from "../storage/schema"; import { readActivePlanContext } from "./active-plan"; import { type ChatRow, + projectLiveAssistant, projectSessionEntries, renderTranscript, } from "./chat-view"; @@ -52,6 +53,13 @@ const STAGE_THEME_COLOR: Record = { 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; + export function registerPlannerDashboard(pi: ExtensionAPI): void { pi.registerCommand("planner-dashboard", { description: @@ -60,6 +68,14 @@ export function registerPlannerDashboard(pi: ExtensionAPI): void { await openPlannerWorkspace(pi, ctx); }, }); + + // Track streaming assistant output so the workspace renders it live. + pi.on("message_update", (event) => { + liveAssistantMessage = (event as { message?: unknown }).message ?? null; + }); + pi.on("message_end", () => { + liveAssistantMessage = null; + }); } export async function openPlannerWorkspace( @@ -90,7 +106,13 @@ export async function openPlannerWorkspace( if (options.auto && !workspace.autoOpen) return; const footerReserve = Math.max(0, workspace.footerReserveRows); const load = () => loadDashboardModel(fs, ctx.cwd); - const getRows = () => projectSessionEntries(ctx.sessionManager.getBranch()); + const getRows = () => { + const rows = projectSessionEntries(ctx.sessionManager.getBranch()); + if (liveAssistantMessage) { + rows.push(...projectLiveAssistant(liveAssistantMessage)); + } + return rows; + }; const initial = await load(); await ctx.ui.custom( (tui, theme, _keybindings, done) => { @@ -199,6 +221,7 @@ class PlannerWorkspaceComponent implements Component { private tick = 0; private reloading = false; private version = 0; + private lastSignature = ""; private lastTranscriptTotal = 0; private lastTranscriptHeight = 1; private cachedWidth = -1; @@ -233,10 +256,12 @@ class PlannerWorkspaceComponent implements Component { private onTick(): void { this.ui.tickerOffset += 1; this.refreshRows(); - this.version += 1; - this.tui.requestRender(); if (this.tick % RELOAD_EVERY_TICKS === 0) void this.reloadModel(); this.tick += 1; + // Only redraw when something visible actually changed (content, clock + // second, marquee step, or focus/input). This keeps the UI calm instead + // of repainting every tick. + this.renderIfChanged(); } private refreshRows(): void { @@ -253,8 +278,7 @@ class PlannerWorkspaceComponent implements Component { try { this.model = await this.load(); this.clampSelection(); - this.version += 1; - this.tui.requestRender(); + this.renderIfChanged(); } catch { // Best-effort. } finally { @@ -262,6 +286,34 @@ class PlannerWorkspaceComponent implements Component { } } + 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(this.model.totalActiveMs) + : "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.chatScroll}|${this.expandAll}`; + // Coarse marquee step (~1 move/sec) so the ticker scrolls without a + // per-tick repaint storm. + const marquee = Math.floor(this.ui.tickerOffset / 5); + return `${clock}#${rowsSig}#${modelSig}#${uiSig}#${marquee}`; + } + + 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) { @@ -392,8 +444,7 @@ class PlannerWorkspaceComponent implements Component { } private bump(): void { - this.version += 1; - this.tui.requestRender(); + this.scheduleRender(); } render(width: number): string[] { From 6dde8901b7dc818bd2242d275bd0054b82243b4d Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 12:36:45 +0500 Subject: [PATCH 07/14] fix: improve flow no longer runs discovery twice after goal approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the discovery-first /planner-improve flow, discovery runs before the goal is drafted. But planner_goal_decide(approve) always routed to discovery/scan_project_structure (the create-flow assumption), so improve plans re-ran the whole discovery stage after approval (re-scan, "questions already submitted", second compact). Now approval routes creationMethod=improve plans straight to planning/read_context, and the state machine allows intake/await_goal_approval → planning/read_context for improve. Create plans are unchanged (goal first, then discovery). Co-Authored-By: Claude Opus 4.8 --- src/runtime/goal-tools.test.ts | 25 +++++++++++++++++++++++++ src/runtime/goal-tools.ts | 14 ++++++++++++-- src/runtime/state-machine.ts | 15 +++++++++++---- src/runtime/status.ts | 2 +- 4 files changed, 49 insertions(+), 7 deletions(-) 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/state-machine.ts b/src/runtime/state-machine.ts index 421a33f..79489b6 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" diff --git a/src/runtime/status.ts b/src/runtime/status.ts index 780b559..e1dbdd5 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", { From a51fe63499da9fa011d42648cb1e3b8d60350baa Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 12:49:33 +0500 Subject: [PATCH 08/14] fix: expand formatting, live dashboard clock, smooth marquee, timer pauses - Sanitize transcript text (expand tabs, strip ANSI/control bytes) so the expand (x) view no longer breaks the frame on tool output that contains tabs or escape codes. - Dashboard clock + stage timings now show live elapsed time (activeMs plus time since last disk sync) instead of the rarely-synced persisted value, so they tick in real time. Timer sync interval is threaded from settings. - Restore a smooth one-cell-per-tick marquee when the ticker overflows; keep the calm render-on-change behaviour when it fits. - Timer pauses only while waiting on the user (goal approval, discovery questions, result acceptance, user decisions). It now counts intake drafting and compaction for honest active timing. Co-Authored-By: Claude Opus 4.8 --- src/runtime/chat-view.test.ts | 23 ++++++++++++++++ src/runtime/chat-view.ts | 20 +++++++++++++- src/runtime/dashboard-model.ts | 50 ++++++++++++++++++++++++++++------ src/runtime/dashboard.ts | 37 +++++++++++++++++-------- src/runtime/timer.test.ts | 43 +++++++++++++++-------------- src/runtime/timer.ts | 7 +++-- 6 files changed, 136 insertions(+), 44 deletions(-) diff --git a/src/runtime/chat-view.test.ts b/src/runtime/chat-view.test.ts index 29f6cf5..7ab0a68 100644 --- a/src/runtime/chat-view.test.ts +++ b/src/runtime/chat-view.test.ts @@ -159,6 +159,29 @@ describe("renderTranscript", () => { 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"); diff --git a/src/runtime/chat-view.ts b/src/runtime/chat-view.ts index 63a1db0..9357435 100644 --- a/src/runtime/chat-view.ts +++ b/src/runtime/chat-view.ts @@ -293,7 +293,25 @@ function bodyColor( // --------------------------------------------------------------------------- function splitText(text: string): string[] { - return text.replace(/\r/g, "").split("\n"); + 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[] { diff --git a/src/runtime/dashboard-model.ts b/src/runtime/dashboard-model.ts index 34474db..8ec6368 100644 --- a/src/runtime/dashboard-model.ts +++ b/src/runtime/dashboard-model.ts @@ -124,9 +124,14 @@ export interface DashboardUiState { 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") { @@ -168,7 +173,11 @@ export function buildPlannerDashboardModel(input: { })); const tasksDone = tasks.filter((task) => task.status === "done").length; - const totalActiveMs = state.timer?.activeMs ?? 0; + const totalActiveMs = liveActiveMs( + state.timer, + input.now, + input.syncMs ?? DEFAULT_TIMER_SYNC_MS, + ); const timings = buildStageTimings(state, totalActiveMs, effectiveStage); const stuck = !recovery && state.lastStuckAttemptId !== null; @@ -225,6 +234,26 @@ function deriveStatusLabel(input: { 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[] = []; @@ -536,12 +565,8 @@ export function renderStageRibbon( return [padTo(parts.join(sep), width, palette)]; } -export function renderTicker( - model: DashboardModel, - width: number, - offset: number, - palette: DashboardPalette, -): string { +/** Full ticker content (no windowing/colour), used for marquee + overflow checks. */ +export function buildTickerContent(model: DashboardModel): string { const segments: string[] = []; if (model.recovery) { segments.push("RECOVERY MODE — resolve before resuming"); @@ -554,9 +579,16 @@ export function renderTicker( if (model.currentBranch) segments.push(`branch ${model.currentBranch}`); if (model.note) segments.push(`note: ${model.note}`); segments.push(`route ${model.routeTrail.join(" → ")}`); + return `${segments.join(" • ")} • `; +} - const gap = " • "; - const full = segments.join(gap) + gap; +export function renderTicker( + model: DashboardModel, + width: number, + offset: number, + palette: DashboardPalette, +): string { + const full = buildTickerContent(model); const fullWidth = full.length; let windowText: string; if (fullWidth <= width) { diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index 4429902..d714912 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -26,6 +26,7 @@ import { } from "./chat-view"; import { buildPlannerDashboardModel, + buildTickerContent, type DashboardPalette, type DashboardUiState, dashboardDivider, @@ -93,8 +94,8 @@ export async function openPlannerWorkspace( return; } const fs = createNodeFs(); - const workspace = await loadWorkspaceSettings(fs, ctx.cwd); - if (!workspace.enabled) { + const config = await loadWorkspaceSettings(fs, ctx.cwd); + if (!config.enabled) { if (!options.auto) { ctx.ui.notify( "The planner workspace is disabled (settings: workspace.enabled).", @@ -103,9 +104,9 @@ export async function openPlannerWorkspace( } return; } - if (options.auto && !workspace.autoOpen) return; - const footerReserve = Math.max(0, workspace.footerReserveRows); - const load = () => loadDashboardModel(fs, ctx.cwd); + if (options.auto && !config.autoOpen) return; + const footerReserve = Math.max(0, config.footerReserveRows); + const load = () => loadDashboardModel(fs, ctx.cwd, config.syncMs); const getRows = () => { const rows = projectSessionEntries(ctx.sessionManager.getBranch()); if (liveAssistantMessage) { @@ -151,7 +152,12 @@ export async function openPlannerWorkspace( async function loadWorkspaceSettings( fs: PlannerFs, cwd: string, -): Promise<{ enabled: boolean; autoOpen: boolean; footerReserveRows: number }> { +): Promise<{ + enabled: boolean; + autoOpen: boolean; + footerReserveRows: number; + syncMs: number; +}> { try { const projectPaths = await resolveProjectStoragePaths({ fs, @@ -159,12 +165,16 @@ async function loadWorkspaceSettings( cwd, }); const settings = await loadEffectivePlannerSettings({ fs, projectPaths }); - return settings.effective.workspace; + return { + ...settings.effective.workspace, + syncMs: settings.effective.timer.syncIntervalMinutes * 60_000, + }; } catch { return { enabled: true, autoOpen: true, footerReserveRows: DEFAULT_FOOTER_RESERVE, + syncMs: 600_000, }; } } @@ -172,6 +182,7 @@ async function loadWorkspaceSettings( async function loadDashboardModel( fs: PlannerFs, cwd: string, + syncMs: number, ): Promise { try { const projectPaths: ProjectStoragePaths = await resolveProjectStoragePaths({ @@ -180,7 +191,7 @@ async function loadDashboardModel( cwd, }); const context = await readActivePlanContext({ fs, projectPaths }); - return buildPlannerDashboardModel({ context, now: Date.now() }); + return buildPlannerDashboardModel({ context, now: Date.now(), syncMs }); } catch (error) { return { available: false, @@ -222,6 +233,7 @@ class PlannerWorkspaceComponent implements Component { private reloading = false; private version = 0; private lastSignature = ""; + private tickerOverflow = false; private lastTranscriptTotal = 0; private lastTranscriptHeight = 1; private cachedWidth = -1; @@ -302,9 +314,9 @@ class PlannerWorkspaceComponent implements Component { ? `${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.chatScroll}|${this.expandAll}`; - // Coarse marquee step (~1 move/sec) so the ticker scrolls without a - // per-tick repaint storm. - const marquee = Math.floor(this.ui.tickerOffset / 5); + // When the ticker overflows, advance it one cell per tick for a smooth + // marquee; otherwise it contributes nothing so the UI stays calm. + const marquee = this.tickerOverflow ? this.ui.tickerOffset : 0; return `${clock}#${rowsSig}#${modelSig}#${uiSig}#${marquee}`; } @@ -460,6 +472,9 @@ class PlannerWorkspaceComponent implements Component { const model = this.model; const inner = width - 2; const bodyHeight = Math.max(1, height - 2); + this.tickerOverflow = model.available + ? buildTickerContent(model).length > inner + : false; const band = renderDashboardBand( model, inner, 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 b2ac144..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" && From d5e2a64d09ff80137907c3942125e95384b4d088 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 13:00:58 +0500 Subject: [PATCH 09/14] feat: per-token streaming, thinking toggle, in-memory live clock, narrow ribbon - Streaming now redraws per message_update event (token by token) instead of on the poll tick, so the chat fills in smoothly like Pi's own chat. - Clock + stage timings recompute live in-memory each draw (applyLiveTiming) and disk reload drops to ~3s, so the terminal shows full-time elapsed without per-second disk reads (persisted timer still syncs every 10 min). - Inherit Pi keybindings: app.thinking.toggle (Ctrl+T) hides/shows thinking rows; app.tools.expand (Ctrl+O) expands/collapses tool output. - Stage ribbon falls back to a compact [ACTIVE] n/total indicator when the terminal is too narrow for the full ribbon, so the active stage is always visible. - Detail-panel note now word-wraps and ellipsises instead of hard-clipping. - Docs: workspace keys + inherited bindings in /planner-helper and SETTINGS.md. Co-Authored-By: Claude Opus 4.8 --- SETTINGS.md | 4 +- src/runtime/about.ts | 3 +- src/runtime/dashboard-model.test.ts | 51 ++++++++++ src/runtime/dashboard-model.ts | 144 +++++++++++++++++++++++++--- src/runtime/dashboard.ts | 59 +++++++++--- 5 files changed, 233 insertions(+), 28 deletions(-) diff --git a/SETTINGS.md b/SETTINGS.md index 18cf0ed..9cc7352 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -151,7 +151,9 @@ Inside the workspace, `Tab` cycles three focus panes: | chat | `↑`/`↓`, `PageUp`/`PageDown` scroll the transcript; `x` toggles expand-all for collapsed tool calls | | tasks | `↑`/`↓` select a task and reveal the task list + stage timings; `←`/`→` nudge the ticker | -`Esc` (or `Ctrl+C`) closes the workspace and returns to the plain chat. Streaming assistant output appears live. +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`. + +`Esc` (or `Ctrl+C`) closes the workspace and returns to the plain chat. Streaming assistant output appears live, token by token. ### Pi keybindings diff --git a/src/runtime/about.ts b/src/runtime/about.ts index f1909f5..8bac960 100644 --- a/src/runtime/about.ts +++ b/src/runtime/about.ts @@ -202,8 +202,9 @@ export function buildPlannerAboutReport(input: { " - input: type and press Enter to send a message to the model.", " - chat: ↑↓ / PageUp / PageDown scroll the transcript; x toggles expand-all for collapsed 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 as it is generated.", + "- 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.", "", diff --git a/src/runtime/dashboard-model.test.ts b/src/runtime/dashboard-model.test.ts index 43668b9..e2024b1 100644 --- a/src/runtime/dashboard-model.test.ts +++ b/src/runtime/dashboard-model.test.ts @@ -16,12 +16,14 @@ import type { ActivePlanContextUnavailable, } from "./active-plan"; import { + applyLiveTiming, buildPlannerDashboardModel, composeDashboard, DASHBOARD_STAGE_SEQUENCE, type DashboardModel, type DashboardPalette, type DashboardUiState, + liveTotalMs, renderStageRibbon, renderTicker, } from "./dashboard-model"; @@ -232,6 +234,55 @@ describe("renderStageRibbon", () => { 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("renderTicker", () => { diff --git a/src/runtime/dashboard-model.ts b/src/runtime/dashboard-model.ts index 8ec6368..85af4b0 100644 --- a/src/runtime/dashboard-model.ts +++ b/src/runtime/dashboard-model.ts @@ -89,6 +89,15 @@ export interface DashboardModel { 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 { @@ -173,12 +182,24 @@ export function buildPlannerDashboardModel(input: { })); const tasksDone = tasks.filter((task) => task.status === "done").length; - const totalActiveMs = liveActiveMs( - state.timer, - input.now, - input.syncMs ?? DEFAULT_TIMER_SYNC_MS, + 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 timings = buildStageTimings(state, totalActiveMs, effectiveStage); const stuck = !recovery && state.lastStuckAttemptId !== null; const blocked = @@ -217,6 +238,44 @@ export function buildPlannerDashboardModel(input: { 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, + ), }; } @@ -276,12 +335,17 @@ function lastSequencedStage(trail: PlannerStage[]): PlannerStage { return "init"; } -function buildStageTimings( - state: PlanStateRecord, +interface TimingCheckpoint { + stage: PlannerStage; + activeMs: number; +} + +function computeStageTimings( + checkpoints: TimingCheckpoint[], + planStage: PlannerStage, totalActiveMs: number, currentStage: PlannerStage, ): DashboardStageTiming[] { - const checkpoints = state.timer?.checkpoints ?? []; const perStage = new Map(); for (let i = 0; i < checkpoints.length; i++) { const start = checkpoints[i].activeMs; @@ -293,16 +357,16 @@ function buildStageTimings( ); } if (perStage.size === 0 && totalActiveMs > 0) { - perStage.set(state.stage, totalActiveMs); + perStage.set(planStage, totalActiveMs); } const order: PlannerStage[] = [...DASHBOARD_STAGE_SEQUENCE]; - if (state.stage === "recovery") order.push("recovery"); + 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 === state.stage, + isCurrent: stage === currentStage || stage === planStage, })); } @@ -549,6 +613,7 @@ export function renderStageRibbon( 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) { @@ -562,7 +627,18 @@ export function renderStageRibbon( // Pending stage: dimmed. return palette.dim(label); }); - return [padTo(parts.join(sep), width, palette)]; + 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)]; } /** Full ticker content (no windowing/colour), used for marquee + overflow checks. */ @@ -745,13 +821,51 @@ export function renderDetailColumn( } if (model.note) { lines.push(blank(width, palette)); - lines.push( - padTo(palette.warning(palette.clip(model.note, width)), 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, diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index d714912..940d5b1 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -2,6 +2,7 @@ import { type ExtensionAPI, type ExtensionContext, getAgentDir, + type KeybindingsManager, type Theme, type ThemeColor, } from "@earendil-works/pi-coding-agent"; @@ -25,6 +26,7 @@ import { renderTranscript, } from "./chat-view"; import { + applyLiveTiming, buildPlannerDashboardModel, buildTickerContent, type DashboardPalette, @@ -32,14 +34,19 @@ import { dashboardDivider, formatClock, frameWorkspace, + liveTotalMs, type PlannerDashboardModel, renderDashboardBand, renderDashboardColumns, } from "./dashboard-model"; const TICK_MS = 180; -/** Reload the model from disk every Nth tick (~1s). */ -const RELOAD_EVERY_TICKS = 6; +/** + * 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; @@ -60,6 +67,8 @@ const STAGE_THEME_COLOR: Record = { * 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", { @@ -70,12 +79,15 @@ export function registerPlannerDashboard(pi: ExtensionAPI): void { }, }); - // Track streaming assistant output so the workspace renders it live. + // 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?.(); }); } @@ -116,10 +128,11 @@ export async function openPlannerWorkspace( }; const initial = await load(); await ctx.ui.custom( - (tui, theme, _keybindings, done) => { + (tui, theme, keybindings, done) => { return new PlannerWorkspaceComponent({ tui, theme, + keybindings, initial, initialRows: getRows(), footerReserve, @@ -213,6 +226,7 @@ class PlannerWorkspaceComponent implements Component { private readonly sendUserMessage: (text: string) => void; private readonly onClose: () => void; private readonly footerReserve: number; + private readonly keybindings: KeybindingsManager; private model: PlannerDashboardModel; private rows: ChatRow[]; @@ -221,6 +235,7 @@ class PlannerWorkspaceComponent implements Component { private focus: WorkspaceFocus = "input"; private chatScroll = 0; private expandAll = false; + private hideThinking = false; private readonly ui: DashboardUiState = { selectedIndex: 0, taskScroll: 0, @@ -244,6 +259,7 @@ class PlannerWorkspaceComponent implements Component { constructor(input: { tui: TUI; theme: Theme; + keybindings: KeybindingsManager; initial: PlannerDashboardModel; initialRows: ChatRow[]; footerReserve: number; @@ -254,6 +270,7 @@ class PlannerWorkspaceComponent implements Component { }) { this.tui = input.tui; this.palette = buildPalette(input.theme); + this.keybindings = input.keybindings; this.footerReserve = input.footerReserve; this.load = input.load; this.getRows = input.getRows; @@ -263,6 +280,11 @@ class PlannerWorkspaceComponent implements Component { this.rows = input.initialRows; this.interval = setInterval(() => this.onTick(), TICK_MS); this.interval.unref?.(); + // Redraw immediately on each streaming token for smooth output. + liveStreamListener = () => { + this.refreshRows(); + this.renderIfChanged(); + }; } private onTick(): void { @@ -308,12 +330,12 @@ class PlannerWorkspaceComponent implements Component { 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(this.model.totalActiveMs) + ? 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.chatScroll}|${this.expandAll}`; + const uiSig = `${this.focus}|${this.input}|${this.cursor}|${this.ui.selectedIndex}|${this.chatScroll}|${this.expandAll}|${this.hideThinking}`; // When the ticker overflows, advance it one cell per tick for a smooth // marquee; otherwise it contributes nothing so the UI stays calm. const marquee = this.tickerOverflow ? this.ui.tickerOffset : 0; @@ -343,6 +365,17 @@ class PlannerWorkspaceComponent implements Component { 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; @@ -469,12 +502,15 @@ class PlannerWorkspaceComponent implements Component { return this.cachedLines; } - const model = this.model; + // 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); - this.tickerOverflow = model.available - ? buildTickerContent(model).length > inner - : false; + this.tickerOverflow = + model.available && buildTickerContent(model).length > inner; const band = renderDashboardBand( model, inner, @@ -512,7 +548,7 @@ class PlannerWorkspaceComponent implements Component { bodyHeight - top.length - bottom.length, ); const transcript = renderTranscript( - this.rows, + rows, { width: inner, height: transcriptHeight, @@ -599,6 +635,7 @@ class PlannerWorkspaceComponent implements Component { clearInterval(this.interval); this.interval = null; } + liveStreamListener = null; } } From 0803c731e560c0a819c9ba0409f9d8af2594010d Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 13:14:20 +0500 Subject: [PATCH 10/14] fix: live header clock, jump-to-live key, text paste in workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header clock now uses the live-recomputed model instead of the value from the last disk reload, so it ticks every second (was stepping in ~3s reload increments). - Chat pane: End jumps back to the live tail (newest output), Home to the top — so after scrolling up you can return to the streaming view. - Input accepts pasted text (bracketed paste handled, ANSI/control bytes stripped, newlines folded to spaces). Image paste is not supported in the workspace (Pi's image paste targets its built-in editor); documented. - Docs: paste + End/Home keys in /planner-helper and SETTINGS.md. Co-Authored-By: Claude Opus 4.8 --- SETTINGS.md | 6 +++-- src/runtime/about.ts | 4 ++-- src/runtime/dashboard.ts | 49 +++++++++++++++++++++++++++++----------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/SETTINGS.md b/SETTINGS.md index 9cc7352..a52de74 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -147,10 +147,12 @@ Inside the workspace, `Tab` cycles three focus panes: | Pane | Keys | | --- | --- | -| input | type, `Enter` to send to the model | -| chat | `↑`/`↓`, `PageUp`/`PageDown` scroll the transcript; `x` toggles expand-all for collapsed tool calls | +| 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 | +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`. `Esc` (or `Ctrl+C`) closes the workspace and returns to the plain chat. Streaming assistant output appears live, token by token. diff --git a/src/runtime/about.ts b/src/runtime/about.ts index 8bac960..2cc9b39 100644 --- a/src/runtime/about.ts +++ b/src/runtime/about.ts @@ -199,8 +199,8 @@ export function buildPlannerAboutReport(input: { "## 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 and press Enter to send a message to the model.", - " - chat: ↑↓ / PageUp / PageDown scroll the transcript; x toggles expand-all for collapsed tool calls.", + " - 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.", diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index 940d5b1..37afbea 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -421,10 +421,13 @@ class PlannerWorkspaceComponent implements Component { this.bump(); return; } - if (isPrintable(data)) { + const insert = toInsertableText(data); + if (insert) { this.input = - this.input.slice(0, this.cursor) + data + this.input.slice(this.cursor); - this.cursor += data.length; + this.input.slice(0, this.cursor) + + insert + + this.input.slice(this.cursor); + this.cursor += insert.length; this.bump(); } } @@ -439,6 +442,16 @@ class PlannerWorkspaceComponent implements Component { this.scrollChat(page); } else if (matchesKey(data, "pageDown")) { this.scrollChat(-page); + } else if (matchesKey(data, "end") || data === "G") { + // Jump back to the live tail (newest output). + this.chatScroll = 0; + this.bump(); + } else if (matchesKey(data, "home") || data === "g") { + this.chatScroll = Math.max( + 0, + this.lastTranscriptTotal - this.lastTranscriptHeight, + ); + this.bump(); } else if (data === "x" || data === "X") { this.expandAll = !this.expandAll; this.bump(); @@ -567,7 +580,7 @@ class PlannerWorkspaceComponent implements Component { width, height, title: this.title(), - clock: this.model.available ? formatClock(this.model.totalActiveMs) : "", + clock: model.available ? formatClock(model.totalActiveMs) : "", body, }); this.cachedWidth = width; @@ -617,9 +630,9 @@ class PlannerWorkspaceComponent implements Component { .join(this.palette.dim(" · ")); const keys = this.focus === "input" - ? this.palette.dim("enter send · tab pane · esc exit") + ? this.palette.dim("enter send · paste ok · tab pane · esc exit") : this.focus === "chat" - ? this.palette.dim("↑↓ scroll · x expand · tab pane · esc exit") + ? 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); } @@ -641,13 +654,23 @@ class PlannerWorkspaceComponent implements Component { const EMPTY_SET: ReadonlySet = new Set(); -function isPrintable(data: string): boolean { - if (data.length === 0) return false; - for (let i = 0; i < data.length; i++) { - const code = data.charCodeAt(i); - if (code < 32 || code === 127) return false; - } - return true; +/** + * 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 { + const bracket = new RegExp("\\u001B\\[20[01]~", "g"); + const ansi = new RegExp("\\u001B\\[[0-9;?]*[ -/]*[@-~]", "g"); + const control = new RegExp( + "[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F]", + "g", + ); + return data + .replace(bracket, "") + .replace(ansi, "") + .replace(/[\r\n\t]/g, " ") + .replace(control, ""); } function clipPad( From 8ea8f9d4809d2fe9062177e4e33e2b1bd44ee05d Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 13:23:53 +0500 Subject: [PATCH 11/14] fix: anchored chat scroll + sliding-window history projection - The transcript now anchors to an absolute top line while scrolled up, so newly streamed/committed output appends below without yanking the view to the bottom. End re-follows the live tail; Home jumps to the top. - Project only a trailing window of session entries (HISTORY_WINDOW=400) and cache the projection, rebuilding only when the windowed slice changes instead of every 180ms tick. Scrolling to the top loads the next older chunk, so long sessions never project the whole conversation at once and the per-tick CPU/alloc cost stays bounded. - renderTranscript switches from scroll-from-bottom to atBottom + absolute topLine and returns the resolved top line for the caller to track. Co-Authored-By: Claude Opus 4.8 --- SETTINGS.md | 2 + src/runtime/chat-view.test.ts | 41 +++++++----- src/runtime/chat-view.ts | 23 ++++--- src/runtime/dashboard.ts | 123 +++++++++++++++++++++++----------- 4 files changed, 127 insertions(+), 62 deletions(-) diff --git a/SETTINGS.md b/SETTINGS.md index a52de74..b74c499 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -151,6 +151,8 @@ Inside the workspace, `Tab` cycles three focus panes: | 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`. diff --git a/src/runtime/chat-view.test.ts b/src/runtime/chat-view.test.ts index 7ab0a68..51f5af1 100644 --- a/src/runtime/chat-view.test.ts +++ b/src/runtime/chat-view.test.ts @@ -38,9 +38,9 @@ function baseOptions(over: Partial = {}): TranscriptOptions { return { width: 60, height: 10, - scrollFromBottom: 0, + atBottom: true, + topLine: 0, expanded: new Set(), - focused: false, ...over, }; } @@ -187,20 +187,31 @@ describe("renderTranscript", () => { expect(result.lines.join("\n")).toContain("No conversation yet"); }); - it("scrolls up from the bottom", () => { - const many: ChatRow[] = Array.from({ length: 40 }, (_, i) => ({ - role: "user" as const, - text: `message ${i}`, - collapsible: false, - key: `m${i}`, - })); - const bottom = renderTranscript(many, baseOptions(), palette); - const scrolled = renderTranscript( - many, - baseOptions({ scrollFromBottom: 5 }), + 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, ); - expect(bottom.lines.join("\n")).not.toBe(scrolled.lines.join("\n")); - expect(bottom.totalLines).toBe(40); + 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 index 9357435..74ea55f 100644 --- a/src/runtime/chat-view.ts +++ b/src/runtime/chat-view.ts @@ -160,16 +160,22 @@ function projectAssistantBlocks(id: string, blocks: unknown[]): ChatRow[] { export interface TranscriptOptions { width: number; height: number; - /** Lines scrolled up from the bottom (0 = pinned to newest). */ - scrollFromBottom: 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; - focused: boolean; } 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( @@ -192,15 +198,16 @@ export function renderTranscript( } const total = all.length; - const maxScroll = Math.max(0, total - height); - const scroll = Math.min(Math.max(0, options.scrollFromBottom), maxScroll); - const end = total - scroll; - const start = Math.max(0, end - height); - const window = all.slice(start, end); + 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, }; } diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index 37afbea..463f603 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -3,6 +3,7 @@ import { type ExtensionContext, getAgentDir, type KeybindingsManager, + type SessionEntry, type Theme, type ThemeColor, } from "@earendil-works/pi-coding-agent"; @@ -49,6 +50,12 @@ const TICK_MS = 180; 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; const STAGE_THEME_COLOR: Record = { init: "syntaxComment", @@ -119,13 +126,7 @@ export async function openPlannerWorkspace( if (options.auto && !config.autoOpen) return; const footerReserve = Math.max(0, config.footerReserveRows); const load = () => loadDashboardModel(fs, ctx.cwd, config.syncMs); - const getRows = () => { - const rows = projectSessionEntries(ctx.sessionManager.getBranch()); - if (liveAssistantMessage) { - rows.push(...projectLiveAssistant(liveAssistantMessage)); - } - return rows; - }; + const getEntries = () => ctx.sessionManager.getBranch(); const initial = await load(); await ctx.ui.custom( (tui, theme, keybindings, done) => { @@ -134,10 +135,9 @@ export async function openPlannerWorkspace( theme, keybindings, initial, - initialRows: getRows(), footerReserve, load, - getRows, + getEntries, sendUserMessage: (text) => pi.sendUserMessage(text), onClose: () => done(undefined), }); @@ -222,20 +222,27 @@ class PlannerWorkspaceComponent implements Component { private readonly tui: TUI; private readonly palette: DashboardPalette; private readonly load: () => Promise; - private readonly getRows: () => ChatRow[]; + private readonly getEntries: () => SessionEntry[]; private readonly sendUserMessage: (text: string) => void; private readonly onClose: () => void; private readonly footerReserve: number; private readonly keybindings: KeybindingsManager; private model: PlannerDashboardModel; - private rows: ChatRow[]; + private rows: ChatRow[] = []; private input = ""; private cursor = 0; private focus: WorkspaceFocus = "input"; - private chatScroll = 0; + /** 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, @@ -261,10 +268,9 @@ class PlannerWorkspaceComponent implements Component { theme: Theme; keybindings: KeybindingsManager; initial: PlannerDashboardModel; - initialRows: ChatRow[]; footerReserve: number; load: () => Promise; - getRows: () => ChatRow[]; + getEntries: () => SessionEntry[]; sendUserMessage: (text: string) => void; onClose: () => void; }) { @@ -273,11 +279,11 @@ class PlannerWorkspaceComponent implements Component { this.keybindings = input.keybindings; this.footerReserve = input.footerReserve; this.load = input.load; - this.getRows = input.getRows; + this.getEntries = input.getEntries; this.sendUserMessage = input.sendUserMessage; this.onClose = input.onClose; this.model = input.initial; - this.rows = input.initialRows; + this.refreshRows(); this.interval = setInterval(() => this.onTick(), TICK_MS); this.interval.unref?.(); // Redraw immediately on each streaming token for smooth output. @@ -300,12 +306,37 @@ class PlannerWorkspaceComponent implements Component { private refreshRows(): void { try { - this.rows = this.getRows(); + 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; @@ -335,7 +366,7 @@ class PlannerWorkspaceComponent implements Component { 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.chatScroll}|${this.expandAll}|${this.hideThinking}`; + const uiSig = `${this.focus}|${this.input}|${this.cursor}|${this.ui.selectedIndex}|${this.atBottom}:${this.topLine}|${this.expandAll}|${this.hideThinking}`; // When the ticker overflows, advance it one cell per tick for a smooth // marquee; otherwise it contributes nothing so the UI stays calm. const marquee = this.tickerOverflow ? this.ui.tickerOffset : 0; @@ -435,22 +466,21 @@ class PlannerWorkspaceComponent implements Component { private handleChatFocus(data: string): void { const page = Math.max(1, this.lastTranscriptHeight - 1); if (matchesKey(data, "up")) { - this.scrollChat(1); + this.scrollBy(-1); } else if (matchesKey(data, "down")) { - this.scrollChat(-1); + this.scrollBy(1); } else if (matchesKey(data, "pageUp")) { - this.scrollChat(page); + this.scrollBy(-page); } else if (matchesKey(data, "pageDown")) { - this.scrollChat(-page); + this.scrollBy(page); } else if (matchesKey(data, "end") || data === "G") { // Jump back to the live tail (newest output). - this.chatScroll = 0; + this.atBottom = true; this.bump(); } else if (matchesKey(data, "home") || data === "g") { - this.chatScroll = Math.max( - 0, - this.lastTranscriptTotal - this.lastTranscriptHeight, - ); + this.atBottom = false; + this.topLine = 0; + if (this.hasMoreHistory) this.growHistory(); this.bump(); } else if (data === "x" || data === "X") { this.expandAll = !this.expandAll; @@ -478,12 +508,26 @@ class PlannerWorkspaceComponent implements Component { } } - private scrollChat(delta: number): void { - const maxScroll = Math.max( + /** + * 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, ); - this.chatScroll = Math.min(maxScroll, Math.max(0, this.chatScroll + delta)); + 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(); } @@ -492,7 +536,7 @@ class PlannerWorkspaceComponent implements Component { if (!text) return; this.input = ""; this.cursor = 0; - this.chatScroll = 0; + this.atBottom = true; try { this.sendUserMessage(text); } catch { @@ -565,14 +609,15 @@ class PlannerWorkspaceComponent implements Component { { width: inner, height: transcriptHeight, - scrollFromBottom: this.chatScroll, + atBottom: this.atBottom, + topLine: this.topLine, expanded: this.expandedKeys(), - focused: this.focus === "chat", }, 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({ @@ -660,12 +705,12 @@ const EMPTY_SET: ReadonlySet = new Set(); * and ANSI escapes, fold tabs/newlines to spaces, strip other control bytes. */ function toInsertableText(data: string): string { - const bracket = new RegExp("\\u001B\\[20[01]~", "g"); - const ansi = new RegExp("\\u001B\\[[0-9;?]*[ -/]*[@-~]", "g"); - const control = new RegExp( - "[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F]", - "g", - ); + // 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, "") From 55df69c86fb59b0efc4aa3f1187e491eb28e91be Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 13:33:44 +0500 Subject: [PATCH 12/14] fix: name the next step at compact_task so the model doesn't flail At a disabled compact boundary (task compaction is off by default), compact_task wants finish_step, not request_compact. The guidance never named the target step, so the model called finish_step with the current step as the target and hit invalid_next_step, then thrashed fail/retry/start before recovering. - Lifecycle: the disabled-compact and generic finish_step messages now state the exact target via getAllowedNextPlannerPositions, and the disabled-compact message explicitly says not to call request_compact. - Orchestrator: blocked-tool reasons now include the lifecycle guidance (with the target), so the model sees the right next step on the block. - status: compact_task rule explains both paths (enabled -> request/complete compact; disabled -> finish_step to execution/select_next_task). - Test: state machine advances compact_task -> select_next_task and rejects finishing into the same step. Co-Authored-By: Claude Opus 4.8 --- src/runtime/lifecycle.ts | 25 +++++++++++++++++++++---- src/runtime/orchestrator.ts | 7 ++++++- src/runtime/state-machine.test.ts | 22 ++++++++++++++++++++++ src/runtime/status.ts | 16 ++++++++++------ 4 files changed, 59 insertions(+), 11 deletions(-) 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..6542d48 100644 --- a/src/runtime/state-machine.test.ts +++ b/src/runtime/state-machine.test.ts @@ -159,6 +159,28 @@ describe("planner state machine", () => { ); }); + 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/status.ts b/src/runtime/status.ts index e1dbdd5..71ea8fb 100644 --- a/src/runtime/status.ts +++ b/src/runtime/status.ts @@ -519,16 +519,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.", From 391f240e3dfda1159c6e832a2ea0e455a8d7a86a Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 13:46:06 +0500 Subject: [PATCH 13/14] perf: drop marquee (CPU fix), static context line, configurable keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The auto-scrolling ticker forced a full repaint every ~180ms, which is what drove the dashboard to ~40% CPU. Replace it with a static, clipped context line (active task / branch / blocking note) — no per-tick repaint. Idle now redraws ~once/second (clock only); when nothing changes the tick is a cheap no-op. - Throttle streaming-driven redraws to ~12fps so a fast token stream cannot drive an unbounded repaint rate. - Make the workspace keys configurable via settings workspace.keys (focusNext/up/down/pageUp/pageDown/jumpBottom/jumpTop/expand/submit/exit), matched through matchesKey; Ctrl+C always exits. Pi's keybindings.json only accepts Pi action ids, so our keys live in planner settings. - Docs updated. Co-Authored-By: Claude Opus 4.8 --- SETTINGS.md | 10 ++ src/runtime/dashboard-model.test.ts | 37 ++++--- src/runtime/dashboard-model.ts | 61 ++++++------ src/runtime/dashboard.ts | 149 ++++++++++++++++++++-------- src/settings/manager.ts | 41 ++++++++ src/settings/schema.ts | 18 ++++ 6 files changed, 227 insertions(+), 89 deletions(-) diff --git a/SETTINGS.md b/SETTINGS.md index b74c499..1952889 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -157,6 +157,16 @@ Pasting text into the input works (bracketed paste is handled; newlines fold to 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 diff --git a/src/runtime/dashboard-model.test.ts b/src/runtime/dashboard-model.test.ts index e2024b1..9966bfc 100644 --- a/src/runtime/dashboard-model.test.ts +++ b/src/runtime/dashboard-model.test.ts @@ -24,8 +24,8 @@ import { type DashboardPalette, type DashboardUiState, liveTotalMs, + renderContextLine, renderStageRibbon, - renderTicker, } from "./dashboard-model"; const identityPalette: DashboardPalette = { @@ -47,7 +47,6 @@ const identityPalette: DashboardPalette = { const defaultUi: DashboardUiState = { selectedIndex: 0, taskScroll: 0, - tickerOffset: 0, focus: "tasks", }; @@ -285,22 +284,30 @@ describe("live timing", () => { }); }); -describe("renderTicker", () => { - it("returns a window of the exact width when content overflows", () => { +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", - }), + 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 a = renderTicker(model, 30, 0, identityPalette); - const b = renderTicker(model, 30, 5, identityPalette); - expect(a.length).toBe(30); - expect(b.length).toBe(30); - expect(a).not.toBe(b); + 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("…"); }); }); diff --git a/src/runtime/dashboard-model.ts b/src/runtime/dashboard-model.ts index 85af4b0..dc81acf 100644 --- a/src/runtime/dashboard-model.ts +++ b/src/runtime/dashboard-model.ts @@ -129,7 +129,6 @@ export interface DashboardPalette { export interface DashboardUiState { selectedIndex: number; taskScroll: number; - tickerOffset: number; focus: "tasks" | "ribbon"; } @@ -477,7 +476,7 @@ function renderFullBody(input: BodyInput): string[] { lines.push(blank(inner, palette)); // Stage ribbon (progress bar) + ticker. lines.push(...renderStageRibbon(model, inner, palette)); - lines.push(renderTicker(model, inner, ui.tickerOffset, palette)); + lines.push(renderContextLine(model, inner, palette)); lines.push(divider(inner, palette)); // Body height available for the two-column area. @@ -520,7 +519,7 @@ function renderCompactBody(input: BodyInput): string[] { const lines: string[] = []; lines.push(renderHeaderLine(model, inner, palette)); lines.push(...renderStageRibbon(model, inner, palette)); - lines.push(renderTicker(model, inner, ui.tickerOffset, palette)); + lines.push(renderContextLine(model, inner, palette)); lines.push(divider(inner, palette)); const used = lines.length; @@ -545,7 +544,6 @@ function renderCompactBody(input: BodyInput): string[] { export function renderDashboardBand( model: PlannerDashboardModel, inner: number, - tickerOffset: number, palette: DashboardPalette, ): string[] { if (!model.available) { @@ -554,7 +552,7 @@ export function renderDashboardBand( return [ renderHeaderLine(model, inner, palette), ...renderStageRibbon(model, inner, palette), - renderTicker(model, inner, tickerOffset, palette), + renderContextLine(model, inner, palette), ]; } @@ -641,40 +639,41 @@ export function renderStageRibbon( return [padTo(position + counter, width, palette)]; } -/** Full ticker content (no windowing/colour), used for marquee + overflow checks. */ -export function buildTickerContent(model: DashboardModel): string { - const segments: string[] = []; - if (model.recovery) { - segments.push("RECOVERY MODE — resolve before resuming"); - } - segments.push(`${model.stage}/${model.step} (${model.stepStatus})`); +/** + * 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); - segments.push(`task ${model.activeTaskId}${title ? `: ${title}` : ""}`); + parts.push( + palette.text(`${model.activeTaskId}${title ? `: ${title}` : ""}`), + ); } - if (model.currentBranch) segments.push(`branch ${model.currentBranch}`); - if (model.note) segments.push(`note: ${model.note}`); - segments.push(`route ${model.routeTrail.join(" → ")}`); - return `${segments.join(" • ")} • `; + 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); } -export function renderTicker( - model: DashboardModel, +function clipEllipsis( + value: string, width: number, - offset: number, palette: DashboardPalette, ): string { - const full = buildTickerContent(model); - const fullWidth = full.length; - let windowText: string; - if (fullWidth <= width) { - windowText = full; - } else { - const start = ((offset % fullWidth) + fullWidth) % fullWidth; - const doubled = full + full; - windowText = doubled.slice(start, start + width); - } - return padTo(palette.muted(windowText), width, palette); + if (palette.measure(value) <= width) return value; + return `${palette.clip(value, Math.max(0, width - 1))}…`; } interface ColumnInput { diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index 463f603..2cf8164 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -9,6 +9,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { type Component, + type KeyId, matchesKey, type TUI, truncateToWidth, @@ -29,7 +30,6 @@ import { import { applyLiveTiming, buildPlannerDashboardModel, - buildTickerContent, type DashboardPalette, type DashboardUiState, dashboardDivider, @@ -56,6 +56,49 @@ const DEFAULT_FOOTER_RESERVE = 3; * 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", @@ -134,6 +177,7 @@ export async function openPlannerWorkspace( tui, theme, keybindings, + keys: config.keys, initial, footerReserve, load, @@ -170,6 +214,7 @@ async function loadWorkspaceSettings( autoOpen: boolean; footerReserveRows: number; syncMs: number; + keys: Record; }> { try { const projectPaths = await resolveProjectStoragePaths({ @@ -178,9 +223,13 @@ async function loadWorkspaceSettings( cwd, }); const settings = await loadEffectivePlannerSettings({ fs, projectPaths }); + const workspace = settings.effective.workspace; return { - ...settings.effective.workspace, + enabled: workspace.enabled, + autoOpen: workspace.autoOpen, + footerReserveRows: workspace.footerReserveRows, syncMs: settings.effective.timer.syncIntervalMinutes * 60_000, + keys: resolveWorkspaceKeys(workspace.keys), }; } catch { return { @@ -188,6 +237,7 @@ async function loadWorkspaceSettings( autoOpen: true, footerReserveRows: DEFAULT_FOOTER_RESERVE, syncMs: 600_000, + keys: resolveWorkspaceKeys(undefined), }; } } @@ -227,6 +277,7 @@ class PlannerWorkspaceComponent implements Component { private readonly onClose: () => void; private readonly footerReserve: number; private readonly keybindings: KeybindingsManager; + private readonly keys: Record; private model: PlannerDashboardModel; private rows: ChatRow[] = []; @@ -246,7 +297,6 @@ class PlannerWorkspaceComponent implements Component { private readonly ui: DashboardUiState = { selectedIndex: 0, taskScroll: 0, - tickerOffset: 0, focus: "tasks", }; @@ -255,7 +305,8 @@ class PlannerWorkspaceComponent implements Component { private reloading = false; private version = 0; private lastSignature = ""; - private tickerOverflow = false; + private lastStreamRenderAt = 0; + private streamFlushTimer: ReturnType | null = null; private lastTranscriptTotal = 0; private lastTranscriptHeight = 1; private cachedWidth = -1; @@ -267,6 +318,7 @@ class PlannerWorkspaceComponent implements Component { tui: TUI; theme: Theme; keybindings: KeybindingsManager; + keys: Record; initial: PlannerDashboardModel; footerReserve: number; load: () => Promise; @@ -277,6 +329,7 @@ class PlannerWorkspaceComponent implements Component { 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; @@ -286,24 +339,41 @@ class PlannerWorkspaceComponent implements Component { this.refreshRows(); this.interval = setInterval(() => this.onTick(), TICK_MS); this.interval.unref?.(); - // Redraw immediately on each streaming token for smooth output. - liveStreamListener = () => { - this.refreshRows(); - this.renderIfChanged(); - }; + // Redraw on streaming tokens, throttled so a fast token stream cannot + // drive an unbounded repaint rate. + liveStreamListener = () => this.onStreamUpdate(); } private onTick(): void { - this.ui.tickerOffset += 1; this.refreshRows(); if (this.tick % RELOAD_EVERY_TICKS === 0) void this.reloadModel(); this.tick += 1; - // Only redraw when something visible actually changed (content, clock - // second, marquee step, or focus/input). This keeps the UI calm instead - // of repainting every tick. + // 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(); @@ -367,10 +437,7 @@ class PlannerWorkspaceComponent implements Component { ? `${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}`; - // When the ticker overflows, advance it one cell per tick for a smooth - // marquee; otherwise it contributes nothing so the UI stays calm. - const marquee = this.tickerOverflow ? this.ui.tickerOffset : 0; - return `${clock}#${rowsSig}#${modelSig}#${uiSig}#${marquee}`; + return `${clock}#${rowsSig}#${modelSig}#${uiSig}`; } private scheduleRender(signature = this.computeSignature()): void { @@ -386,13 +453,18 @@ class PlannerWorkspaceComponent implements Component { } } + private matchesAction(action: WorkspaceAction, data: string): boolean { + return this.keys[action].some((key) => matchesKey(data, key as KeyId)); + } + handleInput(data: string): void { - if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + // 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 (matchesKey(data, "tab")) { + if (this.matchesAction("focusNext", data)) { this.cycleFocus(); return; } @@ -429,7 +501,7 @@ class PlannerWorkspaceComponent implements Component { } private handleInputFocus(data: string): void { - if (matchesKey(data, "enter")) { + if (this.matchesAction("submit", data)) { this.submit(); return; } @@ -465,24 +537,24 @@ class PlannerWorkspaceComponent implements Component { private handleChatFocus(data: string): void { const page = Math.max(1, this.lastTranscriptHeight - 1); - if (matchesKey(data, "up")) { + if (this.matchesAction("up", data)) { this.scrollBy(-1); - } else if (matchesKey(data, "down")) { + } else if (this.matchesAction("down", data)) { this.scrollBy(1); - } else if (matchesKey(data, "pageUp")) { + } else if (this.matchesAction("pageUp", data)) { this.scrollBy(-page); - } else if (matchesKey(data, "pageDown")) { + } else if (this.matchesAction("pageDown", data)) { this.scrollBy(page); - } else if (matchesKey(data, "end") || data === "G") { + } else if (this.matchesAction("jumpBottom", data)) { // Jump back to the live tail (newest output). this.atBottom = true; this.bump(); - } else if (matchesKey(data, "home") || data === "g") { + } else if (this.matchesAction("jumpTop", data)) { this.atBottom = false; this.topLine = 0; if (this.hasMoreHistory) this.growHistory(); this.bump(); - } else if (data === "x" || data === "X") { + } else if (this.matchesAction("expand", data)) { this.expandAll = !this.expandAll; this.bump(); } @@ -490,21 +562,15 @@ class PlannerWorkspaceComponent implements Component { private handleTasksFocus(data: string): void { const total = this.model.available ? this.model.tasks.length : 0; - if (matchesKey(data, "up")) { + if (this.matchesAction("up", data)) { this.ui.selectedIndex = Math.max(0, this.ui.selectedIndex - 1); this.bump(); - } else if (matchesKey(data, "down")) { + } else if (this.matchesAction("down", data)) { this.ui.selectedIndex = Math.min( Math.max(0, total - 1), this.ui.selectedIndex + 1, ); this.bump(); - } else if (matchesKey(data, "left")) { - this.ui.tickerOffset -= 4; - this.bump(); - } else if (matchesKey(data, "right")) { - this.ui.tickerOffset += 4; - this.bump(); } } @@ -566,14 +632,7 @@ class PlannerWorkspaceComponent implements Component { : this.rows; const inner = width - 2; const bodyHeight = Math.max(1, height - 2); - this.tickerOverflow = - model.available && buildTickerContent(model).length > inner; - const band = renderDashboardBand( - model, - inner, - this.ui.tickerOffset, - this.palette, - ); + const band = renderDashboardBand(model, inner, this.palette); const top: string[] = [...band]; if (this.focus === "tasks" && model.available) { @@ -693,6 +752,10 @@ class PlannerWorkspaceComponent implements Component { clearInterval(this.interval); this.interval = null; } + if (this.streamFlushTimer) { + clearTimeout(this.streamFlushTimer); + this.streamFlushTimer = null; + } liveStreamListener = null; } } diff --git a/src/settings/manager.ts b/src/settings/manager.ts index fa61882..ea9bdb6 100644 --- a/src/settings/manager.ts +++ b/src/settings/manager.ts @@ -241,6 +241,47 @@ function normalizeWorkspaceSettings( } 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; } diff --git a/src/settings/schema.ts b/src/settings/schema.ts index 37d489a..e0a1da3 100644 --- a/src/settings/schema.ts +++ b/src/settings/schema.ts @@ -33,8 +33,26 @@ export interface PlannerWorkspaceSettings { 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; From fa7f917897ca364c7d2e2fcdf09db581907cca0b Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 15 Jun 2026 13:48:28 +0500 Subject: [PATCH 14/14] fix: let run_final_tests go back to implement_task to fix late failures When final checks revealed a needed code edit (e.g. a biome import-order error in a new test file), the model was stuck: run_final_tests cannot edit project files, and fail_step/retry_step only re-run the same step. There was no path back to an editing step, so the model thrashed. - State machine: execution/run_final_tests now branches to {capture_skill (forward), implement_task (back to fix)}. - status: run_final_tests rule explains to finish_step into implement_task for fixes (not fail_step), and that this step cannot edit project files. - Test: run_final_tests allows the implement_task branch. Co-Authored-By: Claude Opus 4.8 --- src/runtime/state-machine.test.ts | 17 +++++++++++++++++ src/runtime/state-machine.ts | 8 ++++++++ src/runtime/status.ts | 6 ++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/runtime/state-machine.test.ts b/src/runtime/state-machine.test.ts index 6542d48..fed2c6d 100644 --- a/src/runtime/state-machine.test.ts +++ b/src/runtime/state-machine.test.ts @@ -159,6 +159,23 @@ 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", diff --git a/src/runtime/state-machine.ts b/src/runtime/state-machine.ts index 79489b6..72a74d3 100644 --- a/src/runtime/state-machine.ts +++ b/src/runtime/state-machine.ts @@ -139,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 71ea8fb..4b88050 100644 --- a/src/runtime/status.ts +++ b/src/runtime/status.ts @@ -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: