7e64c4b6d8
- Exported getWorkProfile function and WorkProfile type from main package index for work profiling capabilities. - Updated coding-agent debug module imports to use new main package exports instead of subpath imports.
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
/**
|
|
* Debug command handler with interactive menu.
|
|
*
|
|
* Provides tools for debugging, bug report generation, and system diagnostics.
|
|
*/
|
|
import * as fs from "node:fs/promises";
|
|
import { getWorkProfile } from "@oh-my-pi/pi-natives";
|
|
import { Container, Loader, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
import { getSessionsDir } from "../config";
|
|
import { DynamicBorder } from "../modes/components/dynamic-border";
|
|
import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
|
|
import type { InteractiveModeContext } from "../modes/types";
|
|
import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler";
|
|
import { clearArtifactCache, createReportBundle, getArtifactCacheStats, getRecentLogs } from "./report-bundle";
|
|
import { collectSystemInfo, formatSystemInfo } from "./system-info";
|
|
|
|
/** Debug menu options */
|
|
const DEBUG_MENU_ITEMS: SelectItem[] = [
|
|
{ value: "open-artifacts", label: "Open: artifact folder", description: "Open session artifacts in file manager" },
|
|
{ value: "performance", label: "Report: performance issue", description: "Profile CPU, reproduce, then bundle" },
|
|
{ value: "work", label: "Profile: work scheduling", description: "Open flamegraph of last 30s" },
|
|
{ value: "dump", label: "Report: dump session", description: "Create report bundle immediately" },
|
|
{ value: "memory", label: "Report: memory issue", description: "Heap snapshot + bundle" },
|
|
{ value: "logs", label: "View: recent logs", description: "Show last 50 log entries" },
|
|
{ value: "system", label: "View: system info", description: "Show environment details" },
|
|
{ value: "clear-cache", label: "Clear: artifact cache", description: "Remove old session artifacts" },
|
|
];
|
|
|
|
/**
|
|
* Debug selector component.
|
|
*/
|
|
export class DebugSelectorComponent extends Container {
|
|
private selectList: SelectList;
|
|
|
|
constructor(
|
|
private ctx: InteractiveModeContext,
|
|
onDone: () => void,
|
|
) {
|
|
super();
|
|
|
|
// Title
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", "Debug Tools")), 1, 0));
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Select list
|
|
this.selectList = new SelectList(DEBUG_MENU_ITEMS, 7, getSelectListTheme());
|
|
|
|
this.selectList.onSelect = item => {
|
|
onDone();
|
|
void this.handleSelection(item.value);
|
|
};
|
|
|
|
this.selectList.onCancel = () => {
|
|
onDone();
|
|
};
|
|
|
|
this.addChild(this.selectList);
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
|
|
handleInput(keyData: string): void {
|
|
this.selectList.handleInput(keyData);
|
|
}
|
|
|
|
private async handleSelection(value: string): Promise<void> {
|
|
switch (value) {
|
|
case "open-artifacts":
|
|
await this.handleOpenArtifacts();
|
|
break;
|
|
case "performance":
|
|
await this.handlePerformanceReport();
|
|
break;
|
|
case "work":
|
|
await this.handleWorkReport();
|
|
break;
|
|
case "dump":
|
|
await this.handleDumpReport();
|
|
break;
|
|
case "memory":
|
|
await this.handleMemoryReport();
|
|
break;
|
|
case "logs":
|
|
await this.handleViewLogs();
|
|
break;
|
|
case "system":
|
|
await this.handleViewSystemInfo();
|
|
break;
|
|
case "clear-cache":
|
|
await this.handleClearCache();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async handlePerformanceReport(): Promise<void> {
|
|
// Start profiling
|
|
let session: ProfilerSession;
|
|
try {
|
|
session = await startCpuProfile();
|
|
} catch (err) {
|
|
this.ctx.showError(`Failed to start profiler: ${err instanceof Error ? err.message : String(err)}`);
|
|
return;
|
|
}
|
|
|
|
// Show message and wait for keypress
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", `${theme.status.info} CPU profiling started`), 1, 0));
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(
|
|
new Text(theme.fg("muted", "Reproduce the performance issue, then press Enter to stop profiling."), 1, 0),
|
|
);
|
|
this.ctx.ui.requestRender();
|
|
|
|
// Wait for Enter keypress
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
const originalOnEscape = this.ctx.editor.onEscape;
|
|
const originalOnSubmit = this.ctx.editor.onSubmit;
|
|
|
|
this.ctx.editor.onSubmit = () => {
|
|
this.ctx.editor.onEscape = originalOnEscape;
|
|
this.ctx.editor.onSubmit = originalOnSubmit;
|
|
resolve();
|
|
};
|
|
|
|
this.ctx.editor.onEscape = () => {
|
|
this.ctx.editor.onEscape = originalOnEscape;
|
|
this.ctx.editor.onSubmit = originalOnSubmit;
|
|
resolve();
|
|
};
|
|
|
|
await promise;
|
|
|
|
// Stop profiling and create report
|
|
const loader = new Loader(
|
|
this.ctx.ui,
|
|
spinner => theme.fg("accent", spinner),
|
|
text => theme.fg("muted", text),
|
|
"Generating report...",
|
|
getSymbolTheme().spinnerFrames,
|
|
);
|
|
this.ctx.statusContainer.addChild(loader);
|
|
this.ctx.ui.requestRender();
|
|
|
|
try {
|
|
const cpuProfile = await session.stop();
|
|
const workProfile = getWorkProfile(30);
|
|
const result = await createReportBundle({
|
|
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
|
settings: this.getResolvedSettings(),
|
|
cpuProfile,
|
|
workProfile,
|
|
});
|
|
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(
|
|
new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0),
|
|
);
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", result.path), 1, 0));
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
|
} catch (err) {
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
private async handleWorkReport(): Promise<void> {
|
|
try {
|
|
const workProfile = getWorkProfile(30);
|
|
|
|
if (!workProfile.svg) {
|
|
this.ctx.showWarning(`No work profile data (${workProfile.sampleCount} samples)`);
|
|
return;
|
|
}
|
|
|
|
// Write SVG to temp file and open in browser
|
|
const tmpPath = `/tmp/work-profile-${Date.now()}.svg`;
|
|
await Bun.write(tmpPath, workProfile.svg);
|
|
|
|
const openCmd =
|
|
process.platform === "darwin"
|
|
? ["open", tmpPath]
|
|
: process.platform === "win32"
|
|
? ["cmd", "/c", "start", "", tmpPath]
|
|
: ["xdg-open", tmpPath];
|
|
|
|
Bun.spawn(openCmd, { stdout: "ignore", stderr: "ignore" }).unref();
|
|
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(
|
|
new Text(theme.fg("dim", `Opened flamegraph (${workProfile.sampleCount} samples)`), 1, 0),
|
|
);
|
|
} catch (err) {
|
|
this.ctx.showError(`Failed to open profile: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
private async handleDumpReport(): Promise<void> {
|
|
const loader = new Loader(
|
|
this.ctx.ui,
|
|
spinner => theme.fg("accent", spinner),
|
|
text => theme.fg("muted", text),
|
|
"Creating report bundle...",
|
|
getSymbolTheme().spinnerFrames,
|
|
);
|
|
this.ctx.statusContainer.addChild(loader);
|
|
this.ctx.ui.requestRender();
|
|
|
|
try {
|
|
const result = await createReportBundle({
|
|
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
|
settings: this.getResolvedSettings(),
|
|
});
|
|
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(
|
|
new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0),
|
|
);
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", result.path), 1, 0));
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
|
} catch (err) {
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
private async handleMemoryReport(): Promise<void> {
|
|
const loader = new Loader(
|
|
this.ctx.ui,
|
|
spinner => theme.fg("accent", spinner),
|
|
text => theme.fg("muted", text),
|
|
"Generating heap snapshot...",
|
|
getSymbolTheme().spinnerFrames,
|
|
);
|
|
this.ctx.statusContainer.addChild(loader);
|
|
this.ctx.ui.requestRender();
|
|
|
|
try {
|
|
const heapSnapshot = generateHeapSnapshotData();
|
|
loader.setText("Creating report bundle...");
|
|
|
|
const result = await createReportBundle({
|
|
sessionFile: this.ctx.sessionManager.getSessionFile(),
|
|
settings: this.getResolvedSettings(),
|
|
heapSnapshot,
|
|
});
|
|
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(
|
|
new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0),
|
|
);
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", result.path), 1, 0));
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
|
} catch (err) {
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
private async handleViewLogs(): Promise<void> {
|
|
try {
|
|
const logs = await getRecentLogs(50);
|
|
if (!logs) {
|
|
this.ctx.showWarning("No log entries found for today.");
|
|
return;
|
|
}
|
|
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Recent Logs")), 1, 0));
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
|
|
// Display logs with dim styling
|
|
const lines = logs.split("\n").slice(-50);
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
|
|
}
|
|
}
|
|
|
|
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
} catch (err) {
|
|
this.ctx.showError(`Failed to read logs: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
private async handleViewSystemInfo(): Promise<void> {
|
|
try {
|
|
const info = await collectSystemInfo();
|
|
const formatted = formatSystemInfo(info);
|
|
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
this.ctx.chatContainer.addChild(new Text(formatted, 1, 0));
|
|
this.ctx.chatContainer.addChild(new DynamicBorder());
|
|
} catch (err) {
|
|
this.ctx.showError(`Failed to collect system info: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
private async handleOpenArtifacts(): Promise<void> {
|
|
const sessionFile = this.ctx.sessionManager.getSessionFile();
|
|
if (!sessionFile) {
|
|
this.ctx.showWarning("No active session file.");
|
|
return;
|
|
}
|
|
|
|
const artifactsDir = sessionFile.slice(0, -6);
|
|
|
|
try {
|
|
const stat = await fs.stat(artifactsDir);
|
|
if (!stat.isDirectory()) {
|
|
this.ctx.showWarning("Artifact folder does not exist yet.");
|
|
return;
|
|
}
|
|
} catch {
|
|
this.ctx.showWarning("Artifact folder does not exist yet.");
|
|
return;
|
|
}
|
|
|
|
const openArgs =
|
|
process.platform === "darwin"
|
|
? ["open", artifactsDir]
|
|
: process.platform === "win32"
|
|
? ["cmd", "/c", "start", "", artifactsDir]
|
|
: ["xdg-open", artifactsDir];
|
|
const [cmd, ...args] = openArgs;
|
|
|
|
try {
|
|
Bun.spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true }).unref();
|
|
this.ctx.showStatus(`Opened: ${artifactsDir}`);
|
|
} catch (err) {
|
|
this.ctx.showError(`Failed to open artifact folder: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
private async handleClearCache(): Promise<void> {
|
|
const sessionsDir = getSessionsDir();
|
|
|
|
// Get stats first
|
|
const stats = await getArtifactCacheStats(sessionsDir);
|
|
|
|
if (stats.count === 0) {
|
|
this.ctx.showStatus("Artifact cache is empty.");
|
|
return;
|
|
}
|
|
|
|
const sizeStr = formatBytes(stats.totalSize);
|
|
const oldestStr = stats.oldestDate ? stats.oldestDate.toLocaleDateString() : "unknown";
|
|
|
|
// Show confirmation
|
|
const confirmed = await this.ctx.showHookConfirm(
|
|
"Clear Artifact Cache",
|
|
`Found ${stats.count} artifact files (${sizeStr})\nOldest: ${oldestStr}\n\nRemove artifacts older than 30 days?`,
|
|
);
|
|
|
|
if (!confirmed) {
|
|
this.ctx.showStatus("Cache clear cancelled.");
|
|
return;
|
|
}
|
|
|
|
// Clear cache
|
|
const loader = new Loader(
|
|
this.ctx.ui,
|
|
spinner => theme.fg("accent", spinner),
|
|
text => theme.fg("muted", text),
|
|
"Clearing artifact cache...",
|
|
getSymbolTheme().spinnerFrames,
|
|
);
|
|
this.ctx.statusContainer.addChild(loader);
|
|
this.ctx.ui.requestRender();
|
|
|
|
try {
|
|
const result = await clearArtifactCache(sessionsDir, 30);
|
|
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
|
|
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
this.ctx.chatContainer.addChild(
|
|
new Text(
|
|
theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`),
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
loader.stop();
|
|
this.ctx.statusContainer.clear();
|
|
this.ctx.showError(`Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
private getResolvedSettings(): Record<string, unknown> {
|
|
// Extract key settings for the report
|
|
return {
|
|
model: this.ctx.session.model?.id,
|
|
thinkingLevel: this.ctx.session.thinkingLevel,
|
|
planModeEnabled: this.ctx.planModeEnabled,
|
|
toolOutputExpanded: this.ctx.toolOutputExpanded,
|
|
hideThinkingBlock: this.ctx.hideThinkingBlock,
|
|
};
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
/**
|
|
* Show the debug selector.
|
|
*/
|
|
export function showDebugSelector(ctx: InteractiveModeContext, done: () => void): DebugSelectorComponent {
|
|
const selector = new DebugSelectorComponent(ctx, done);
|
|
return selector;
|
|
}
|