import { existsSync, readFileSync, rmSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { truncateToWidth } from "@mariozechner/pi-tui"; type LiveStatusState = { active: boolean; turn: number; phase: string; workflow?: string; model?: string; lastTool?: string; lastToolTarget?: string; lastResult?: string; }; const STATUS_KEY = "abulafia-live-status"; const STATUS_FILE = resolvePath("outputs", ".status", "abulafia-live-status.md"); const DEFAULT_WORKING_MESSAGE = "Ход работы: готово, ожидание следующего запроса"; let pollTimer: ReturnType | undefined; function compact(text: string, maxLength = 140): string { const normalized = text.replace(/\s+/g, " ").trim(); if (normalized.length <= maxLength) return normalized; return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; } function truncateForTui(text: string, maxVisible: number): string { const width = Math.max(1, maxVisible); return truncateToWidth(text, width, width <= 3 ? "" : "..."); } function widgetLineWidth(width: number): number { return Math.max(1, Math.min(width - 2, 100)); } function modelLabel(ctx: ExtensionContext): string { return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "model: unknown"; } function statusPath(cwd: string): string { return resolvePath(cwd, STATUS_FILE); } function clearLiveStatus(cwd: string): void { rmSync(statusPath(cwd), { force: true }); } function stripMarkdown(line: string): string { return line .replace(/^#{1,6}\s+/, "") .replace(/^[-*]\s+/, "") .replace(/^\d+\.\s+/, "") .replace(/\*\*/g, "") .replace(/`([^`]+)`/g, "$1") .trim(); } function isRunningLiveStatus(lines: string[]): boolean { return lines.some((line) => /^Stage:\s*(?:story running|local\s+story\s+runner)\b/i.test(line)); } function isCompleteLiveStatus(lines: string[]): boolean { return lines.some((line) => /^Stage:\s*story complete\b/i.test(line) || /^Story complete\b/i.test(line)); } function readLiveStatusLines(cwd: string, stateActive: boolean): string[] { if (!stateActive) return []; const filePath = statusPath(cwd); if (!existsSync(filePath)) return []; try { const lines = readFileSync(filePath, "utf8") .split(/\r?\n/) .map(stripMarkdown) .filter((line) => line && !line.match(/^---+$/)) .slice(0, 8) .map((line) => compact(line, 120)); if (isCompleteLiveStatus(lines)) return []; return lines; } catch { return []; } } function inferWorkflow(prompt: string): string | undefined { const story = prompt.match(/Run story-agent workflow:\s*([^\n]+)/i)?.[1]; if (story) return `story ${compact(story, 70)}`; const workflow = prompt.match(/^Run\s+([^:\n]+):\s*([^\n]+)/im); if (workflow) return compact(`${workflow[1]} ${workflow[2]}`, 80); const firstLine = prompt.split(/\r?\n/).find((line) => line.trim().length > 0); return firstLine ? compact(firstLine, 80) : undefined; } function targetFromArgs(args: unknown): string | undefined { if (!args || typeof args !== "object") return undefined; const record = args as Record; for (const key of ["path", "file", "cwd", "query", "pattern", "command", "url"]) { const value = record[key]; if (typeof value === "string" && value.trim()) { return compact(value, key === "command" ? 90 : 70); } } return undefined; } function summarizeState(state: LiveStatusState, ctx: ExtensionContext, liveLines: string[]): string { const pieces = [ state.workflow, state.phase, state.lastTool ? `tool: ${state.lastTool}${state.lastToolTarget ? ` -> ${state.lastToolTarget}` : ""}` : undefined, liveLines[0], ].filter(Boolean); return compact(`Ход работы: ${pieces.join(" | ") || modelLabel(ctx)}`, 180); } function widgetLines(state: LiveStatusState, ctx: ExtensionContext, liveLines: string[]): string[] { const lines = [ `Ход работы: ${state.active ? "идет выполнение" : "готово, ожидание следующего запроса"}`, `Модель: ${state.model ?? modelLabel(ctx)}`, `Этап: ${state.phase}`, ]; if (state.workflow) lines.push(`Workflow: ${state.workflow}`); if (state.lastTool) { lines.push(`Инструмент: ${state.lastTool}${state.lastToolTarget ? ` -> ${state.lastToolTarget}` : ""}`); } if (state.lastResult) lines.push(`Последний результат: ${state.lastResult}`); if (liveLines.length) { lines.push("Live status:"); for (const line of liveLines.slice(0, 5)) lines.push(`- ${line}`); } else { lines.push(`Live status file: ${STATUS_FILE}`); } return lines; } function applyStatus(ctx: ExtensionContext, state: LiveStatusState): void { if (!ctx.hasUI) return; const liveLines = readLiveStatusLines(ctx.cwd, state.active); const summary = truncateForTui(summarizeState(state, ctx, liveLines), 100); const widgetSnapshot = widgetLines(state, ctx, liveLines); const theme = ctx.ui.theme; ctx.ui.setWorkingMessage(summary); ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", summary)); ctx.ui.setWidget( STATUS_KEY, () => ({ render(width: number): string[] { const maxWidth = widgetLineWidth(width); return widgetSnapshot.map((line) => truncateForTui(line, maxWidth)); }, invalidate() {}, }), { placement: "aboveEditor" }, ); } function startPolling(ctx: ExtensionContext, state: LiveStatusState): void { if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(() => applyStatus(ctx, state), 1000); } function stopPolling(): void { if (!pollTimer) return; clearInterval(pollTimer); pollTimer = undefined; } export function registerLiveStatus(pi: ExtensionAPI): void { const state: LiveStatusState = { active: false, turn: 0, phase: "готово, ожидание запроса", }; pi.on("session_start", async (_event, ctx) => { clearLiveStatus(ctx.cwd); state.active = false; state.workflow = undefined; state.lastTool = undefined; state.lastToolTarget = undefined; state.lastResult = undefined; state.model = modelLabel(ctx); state.phase = "готово, ожидание запроса"; if (ctx.hasUI) { ctx.ui.setHiddenThinkingLabel("Ход работы модели"); ctx.ui.setWorkingMessage(DEFAULT_WORKING_MESSAGE); } applyStatus(ctx, state); }); pi.on("before_agent_start", async (event, ctx) => { clearLiveStatus(ctx.cwd); state.active = true; state.model = modelLabel(ctx); state.workflow = inferWorkflow(event.prompt); state.phase = "разбор задачи и выбор маршрута"; state.lastTool = undefined; state.lastToolTarget = undefined; state.lastResult = undefined; applyStatus(ctx, state); }); pi.on("agent_start", async (_event, ctx) => { state.active = true; state.model = modelLabel(ctx); state.phase = "модель формирует следующий публичный шаг"; applyStatus(ctx, state); startPolling(ctx, state); }); pi.on("turn_start", async (event, ctx) => { state.turn = event.turnIndex + 1; state.phase = `ход ${state.turn}: проверка контекста и доступных инструментов`; applyStatus(ctx, state); }); pi.on("tool_execution_start", async (event, ctx) => { state.phase = `ход ${state.turn}: выполняется инструмент`; state.lastTool = event.toolName; state.lastToolTarget = targetFromArgs(event.args); state.lastResult = undefined; applyStatus(ctx, state); }); pi.on("tool_execution_end", async (event, ctx) => { state.phase = `ход ${state.turn}: обработка результата инструмента`; state.lastTool = event.toolName; state.lastResult = event.isError ? "ошибка, будет зафиксирована как blocker" : "успешно, результат включается в контекст"; applyStatus(ctx, state); }); pi.on("turn_end", async (_event, ctx) => { state.phase = `ход ${state.turn}: промежуточный ответ готов`; applyStatus(ctx, state); }); pi.on("agent_end", async (_event, ctx) => { state.active = false; state.phase = "выполнение завершено"; applyStatus(ctx, state); stopPolling(); }); pi.on("session_shutdown", async () => { stopPolling(); }); }