mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-07-07 04:30:11 +08:00
8ba835df47
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
270 lines
9.1 KiB
TypeScript
270 lines
9.1 KiB
TypeScript
/**
|
||
* task-widget.ts — Persistent widget showing task list with status icons and progress.
|
||
*
|
||
* Display style matches Claude Code's task list:
|
||
* ✔ completed tasks (strikethrough + dim)
|
||
* ◼ in_progress tasks
|
||
* ◻ pending tasks
|
||
* ✳/✽ actively executing task (star spinner with activeForm text)
|
||
*/
|
||
|
||
import { truncateToWidth } from "@mariozechner/pi-tui";
|
||
import type { TaskStore } from "../task-store.js";
|
||
|
||
// ---- Types ----
|
||
|
||
export type Theme = {
|
||
fg(color: string, text: string): string;
|
||
bold(text: string): string;
|
||
strikethrough(text: string): string;
|
||
};
|
||
|
||
export type UICtx = {
|
||
setStatus(key: string, text: string | undefined): void;
|
||
setWidget(
|
||
key: string,
|
||
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
|
||
options?: { placement?: "aboveEditor" | "belowEditor" },
|
||
): void;
|
||
};
|
||
|
||
/** Star spinner frames for animated active task indicator (matches Claude Code). */
|
||
const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽"];
|
||
|
||
const MAX_VISIBLE_TASKS = 10;
|
||
|
||
/** Per-task runtime metrics (elapsed time, token usage). */
|
||
export interface TaskMetrics {
|
||
startedAt: number;
|
||
inputTokens: number;
|
||
outputTokens: number;
|
||
}
|
||
|
||
/** Format milliseconds as a human-readable duration (e.g., "2m 49s", "1h 3m"). */
|
||
function formatDuration(ms: number): string {
|
||
const totalSec = Math.floor(ms / 1000);
|
||
if (totalSec < 60) return `${totalSec}s`;
|
||
const min = Math.floor(totalSec / 60);
|
||
const sec = totalSec % 60;
|
||
if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
||
const hr = Math.floor(min / 60);
|
||
const remMin = min % 60;
|
||
return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
|
||
}
|
||
|
||
/** Format token count with k suffix (e.g., "4.1k", "850"). */
|
||
function formatTokens(n: number): string {
|
||
if (n < 1000) return String(n);
|
||
return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
||
}
|
||
|
||
// ---- Widget ----
|
||
|
||
export class TaskWidget {
|
||
private uiCtx: UICtx | undefined;
|
||
private widgetFrame = 0;
|
||
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
||
/** IDs of tasks currently being actively executed (show spinner). */
|
||
private activeTaskIds = new Set<string>();
|
||
/** Per-task runtime metrics keyed by task ID. */
|
||
private metrics = new Map<string, TaskMetrics>();
|
||
/** Cached TUI instance for requestRender() calls. */
|
||
private tui: any | undefined;
|
||
/** Whether the widget callback is currently registered. */
|
||
private widgetRegistered = false;
|
||
|
||
constructor(private store: TaskStore) {}
|
||
|
||
setStore(store: TaskStore) {
|
||
this.store = store;
|
||
}
|
||
|
||
setUICtx(ctx: UICtx) {
|
||
this.uiCtx = ctx;
|
||
}
|
||
|
||
/** Add or remove a task from the active spinner set. */
|
||
setActiveTask(taskId: string | undefined, active = true) {
|
||
if (taskId && active) {
|
||
this.activeTaskIds.add(taskId);
|
||
if (!this.metrics.has(taskId)) {
|
||
this.metrics.set(taskId, { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 });
|
||
}
|
||
this.ensureTimer();
|
||
} else if (taskId) {
|
||
this.activeTaskIds.delete(taskId);
|
||
}
|
||
this.update();
|
||
}
|
||
|
||
/** Record token usage for the currently active task(s). */
|
||
addTokenUsage(inputTokens: number, outputTokens: number) {
|
||
// Distribute to all currently active tasks
|
||
for (const id of this.activeTaskIds) {
|
||
const m = this.metrics.get(id);
|
||
if (m) {
|
||
m.inputTokens += inputTokens;
|
||
m.outputTokens += outputTokens;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Ensure the widget update timer is running. */
|
||
ensureTimer() {
|
||
if (!this.widgetInterval) {
|
||
this.widgetInterval = setInterval(() => this.update(), 80);
|
||
}
|
||
}
|
||
|
||
/** Build widget lines from current live state. Called from the render callback. */
|
||
private renderWidget(tui: any, theme: Theme): string[] {
|
||
const tasks = this.store.list();
|
||
const w = tui.terminal.columns;
|
||
const truncate = (line: string) => truncateToWidth(line, w);
|
||
|
||
if (tasks.length === 0) return [];
|
||
|
||
const completed = tasks.filter(t => t.status === "completed");
|
||
const inProgress = tasks.filter(t => t.status === "in_progress");
|
||
const pending = tasks.filter(t => t.status === "pending");
|
||
|
||
const parts: string[] = [];
|
||
if (completed.length > 0) parts.push(`${completed.length} done`);
|
||
if (inProgress.length > 0) parts.push(`${inProgress.length} in progress`);
|
||
if (pending.length > 0) parts.push(`${pending.length} open`);
|
||
const statusText = `${tasks.length} lgtm's (${parts.join(", ")})`;
|
||
|
||
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
||
const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))];
|
||
|
||
const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
|
||
for (let i = 0; i < visible.length; i++) {
|
||
const task = visible[i];
|
||
const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
||
|
||
let icon: string;
|
||
if (isActive) {
|
||
icon = theme.fg("accent", spinnerChar);
|
||
} else if (task.status === "completed") {
|
||
icon = theme.fg("success", "✔");
|
||
} else if (task.status === "in_progress") {
|
||
icon = theme.fg("accent", "◼");
|
||
} else {
|
||
icon = "◻";
|
||
}
|
||
|
||
let suffix = "";
|
||
if (task.status === "pending" && task.blockedBy.length > 0) {
|
||
const openBlockers = task.blockedBy.filter(bid => {
|
||
const blocker = this.store.get(bid);
|
||
return blocker && blocker.status !== "completed";
|
||
});
|
||
if (openBlockers.length > 0) {
|
||
suffix = theme.fg("dim", ` › blocked by ${openBlockers.map(id => "#" + id).join(", ")}`);
|
||
}
|
||
}
|
||
|
||
let text: string;
|
||
if (isActive) {
|
||
const form = task.activeForm || task.subject;
|
||
const agentId = task.metadata?.agentId;
|
||
const agentLabel = agentId ? ` (agent ${agentId.slice(0, 5)})` : "";
|
||
const m = this.metrics.get(task.id);
|
||
let stats = "";
|
||
if (m) {
|
||
const elapsed = formatDuration(Date.now() - m.startedAt);
|
||
const tokenParts: string[] = [];
|
||
if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
|
||
if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
|
||
stats = tokenParts.length > 0
|
||
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
|
||
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
||
}
|
||
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
|
||
} else if (task.status === "completed") {
|
||
text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}`;
|
||
} else {
|
||
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
|
||
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
|
||
: "";
|
||
const approvalSuffix = (task as any).pending_approval ? " 👀" : "";
|
||
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${approvalSuffix}`;
|
||
}
|
||
|
||
lines.push(truncate(text + suffix));
|
||
if (!isActive && task.status !== "completed" && (task as any).done_criterion) {
|
||
lines.push(truncate(` test: ${(task as any).done_criterion}`));
|
||
}
|
||
}
|
||
|
||
if (tasks.length > MAX_VISIBLE_TASKS) {
|
||
lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
/** Force an immediate widget update. */
|
||
update() {
|
||
if (!this.uiCtx) return;
|
||
const tasks = this.store.list();
|
||
|
||
// Transition: visible → hidden
|
||
if (tasks.length === 0) {
|
||
if (this.widgetRegistered) {
|
||
this.uiCtx.setWidget("tasks", undefined);
|
||
this.widgetRegistered = false;
|
||
}
|
||
if (this.widgetInterval) {
|
||
clearInterval(this.widgetInterval);
|
||
this.widgetInterval = undefined;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Prune stale active IDs (deleted or no longer in_progress)
|
||
for (const id of this.activeTaskIds) {
|
||
const t = this.store.get(id);
|
||
if (!t || t.status !== "in_progress") {
|
||
this.activeTaskIds.delete(id);
|
||
this.metrics.delete(id);
|
||
}
|
||
}
|
||
|
||
// Check if any task needs animation
|
||
const hasActiveSpinner = tasks.some(t => this.activeTaskIds.has(t.id) && t.status === "in_progress");
|
||
if (hasActiveSpinner) {
|
||
this.ensureTimer();
|
||
} else if (!hasActiveSpinner && this.widgetInterval) {
|
||
clearInterval(this.widgetInterval);
|
||
this.widgetInterval = undefined;
|
||
}
|
||
|
||
this.widgetFrame++;
|
||
|
||
// Transition: hidden → visible — register widget callback once
|
||
if (!this.widgetRegistered) {
|
||
this.uiCtx.setWidget("tasks", (tui, theme) => {
|
||
this.tui = tui;
|
||
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} };
|
||
}, { placement: "aboveEditor" });
|
||
this.widgetRegistered = true;
|
||
} else if (this.tui) {
|
||
// Widget already registered — just request a re-render
|
||
this.tui.requestRender();
|
||
}
|
||
}
|
||
|
||
dispose() {
|
||
if (this.widgetInterval) {
|
||
clearInterval(this.widgetInterval);
|
||
this.widgetInterval = undefined;
|
||
}
|
||
if (this.uiCtx) {
|
||
this.uiCtx.setWidget("tasks", undefined);
|
||
}
|
||
this.widgetRegistered = false;
|
||
this.tui = undefined;
|
||
}
|
||
}
|