Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "./reminder-cadence.js";
import { TaskStore } from "./task-store.js";
import { loadTasksConfig } from "./tasks-config.js";
import { isCompletedTaskExecutionStats, isTaskExecutionStats } from "./types.js";
import { openSettingsMenu } from "./ui/settings-menu.js";
import { TaskWidget, type UICtx } from "./ui/task-widget.js";

Expand All @@ -46,6 +47,30 @@ function textResult(msg: string) {
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
}

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`;
}

function formatTokens(n: number): string {
if (n < 1000) return String(n);
return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
}

function formatClockTime(ms: number): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
}).format(ms);
}

/** Task tool names — used to detect task tool usage for reminder suppression. */
const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop", "TaskExecute"]);

Expand Down Expand Up @@ -620,10 +645,33 @@ Returns full task details:
lines.push(`Blocks: ${task.blocks.map(id => "#" + id).join(", ")}`);
}

// Show metadata if non-empty
const metaKeys = Object.keys(task.metadata);
const executionStats = isTaskExecutionStats(task.metadata.executionStats)
? task.metadata.executionStats
: undefined;
const completedStats = isCompletedTaskExecutionStats(task.metadata.executionStats)
? task.metadata.executionStats
: undefined;
if (completedStats) {
const tokenParts: string[] = [];
if ((completedStats.inputTokens ?? 0) > 0) tokenParts.push(`↑ ${formatTokens(completedStats.inputTokens ?? 0)}`);
if ((completedStats.outputTokens ?? 0) > 0) tokenParts.push(`↓ ${formatTokens(completedStats.outputTokens ?? 0)}`);
lines.push(
`Execution stats: started ${formatClockTime(completedStats.startedAt)} · ` +
`ended ${formatClockTime(completedStats.completedAt ?? 0)} · ` +
`${formatDuration(completedStats.durationMs ?? 0)}` +
(tokenParts.length > 0 ? ` · ${tokenParts.join(" ")}` : "")
);
} else if (executionStats) {
lines.push(`Execution stats: started ${formatClockTime(executionStats.startedAt)}`);
}

// Show metadata if non-empty. When execution stats are valid, render them separately.
const metadataForDisplay = executionStats
? Object.fromEntries(Object.entries(task.metadata).filter(([key]) => key !== "executionStats"))
: task.metadata;
const metaKeys = Object.keys(metadataForDisplay);
if (metaKeys.length > 0) {
lines.push(`Metadata: ${JSON.stringify(task.metadata)}`);
lines.push(`Metadata: ${JSON.stringify(metadataForDisplay)}`);
}

return Promise.resolve(textResult(lines.join("\n")));
Expand Down
29 changes: 28 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,41 @@

export type TaskStatus = "pending" | "in_progress" | "completed";

export interface TaskExecutionStats {
startedAt: number;
completedAt?: number;
durationMs?: number;
inputTokens?: number;
outputTokens?: number;
}

export function isTaskExecutionStats(value: unknown): value is TaskExecutionStats {
if (!value || typeof value !== "object") return false;
const stats = value as Record<string, unknown>;
if (typeof stats.startedAt !== "number" || !Number.isFinite(stats.startedAt)) return false;
for (const key of ["completedAt", "durationMs", "inputTokens", "outputTokens"] as const) {
if (stats[key] !== undefined && (typeof stats[key] !== "number" || !Number.isFinite(stats[key]))) {
return false;
}
}
return true;
}

export function isCompletedTaskExecutionStats(value: unknown): value is Required<TaskExecutionStats> {
if (!isTaskExecutionStats(value)) return false;
const stats = value as TaskExecutionStats;
return [stats.completedAt, stats.durationMs, stats.inputTokens, stats.outputTokens]
.every((part) => typeof part === "number" && Number.isFinite(part));
}

export interface Task {
id: string;
subject: string;
description: string;
status: TaskStatus;
activeForm?: string;
owner?: string;
metadata: Record<string, any>;
metadata: Record<string, any> & { executionStats?: TaskExecutionStats };
blocks: string[];
blockedBy: string[];
createdAt: number;
Expand Down
188 changes: 163 additions & 25 deletions src/ui/task-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@
import { truncateToWidth } from "@earendil-works/pi-tui";
import type { TaskStore } from "../task-store.js";
import type { TasksConfig } from "../tasks-config.js";
import { isCompletedTaskExecutionStats, isTaskExecutionStats, type Task, type TaskExecutionStats } from "../types.js";

// ---- Truncation ----

import type { Task } from "../types.js";

function truncateFromTop(tasks: Task[], limit: number): Task[] {
return tasks.slice(-limit);
}
Expand Down Expand Up @@ -73,6 +72,27 @@ function formatTokens(n: number): string {
return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
}

/** Format a stable, human-readable clock time with second precision. */
function formatClockTime(ms: number): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
}).format(ms);
}

function formatLiveStats(theme: Theme, metrics: TaskMetrics | undefined): string {
if (!metrics) return "";

const elapsed = formatDuration(Date.now() - metrics.startedAt);
const tokenParts: string[] = [];
if (metrics.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(metrics.inputTokens)}`);
if (metrics.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(metrics.outputTokens)}`);

const statParts = [`started ${formatClockTime(metrics.startedAt)}`, elapsed, ...tokenParts];
return ` ${theme.fg("dim", `(${statParts.join(" · ")})`)}`;
}

// ---- Widget ----

export class TaskWidget {
Expand Down Expand Up @@ -101,16 +121,136 @@ export class TaskWidget {
this.uiCtx = ctx;
}

/** Persist the fact that a task started even before it completes. */
private persistStartMetrics(taskId: string, startedAt: number, existingStats?: TaskExecutionStats) {
this.store.update(taskId, {
metadata: {
executionStats: {
...existingStats,
startedAt,
inputTokens: existingStats?.inputTokens ?? 0,
outputTokens: existingStats?.outputTokens ?? 0,
},
},
});
}

/** Infer a reasonable execution window for completed tasks that missed live tracking. */
private inferCompletedStats(task: Task, metrics?: TaskMetrics): Required<TaskExecutionStats> {
const existingStats = isTaskExecutionStats(task.metadata?.executionStats)
? task.metadata.executionStats
: undefined;
if (metrics) {
const startedAt = existingStats?.startedAt ?? metrics.startedAt;
const completedAt = existingStats?.completedAt ?? task.updatedAt;
return {
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
inputTokens: metrics.inputTokens,
outputTokens: metrics.outputTokens,
};
}
if (isCompletedTaskExecutionStats(existingStats)) return existingStats;

const blockerCompletedAt = task.blockedBy
.map((id) => this.store.get(id))
.flatMap((blocker) => {
if (!blocker || blocker.status !== "completed") return [];
const blockerStats = isTaskExecutionStats(blocker.metadata?.executionStats)
? blocker.metadata.executionStats
: undefined;
return [blockerStats?.completedAt ?? blocker.updatedAt];
});
const startedAt = Math.max(task.createdAt, ...blockerCompletedAt);
return {
startedAt,
completedAt: task.updatedAt,
durationMs: Math.max(0, task.updatedAt - startedAt),
inputTokens: 0,
outputTokens: 0,
};
}

/** Persist live metrics into task metadata when execution completes. */
private persistMetrics(taskId: string, task?: Task) {
const m = this.metrics.get(taskId);
const existingStats = isTaskExecutionStats(task?.metadata?.executionStats)
? task.metadata.executionStats
: undefined;

if (task?.status === "completed" && (!isCompletedTaskExecutionStats(existingStats) || m)) {
this.store.update(taskId, { metadata: { executionStats: this.inferCompletedStats(task, m) } });
}

if (m) {
this.metrics.delete(taskId);
}
}

/** Rebuild timing baselines for persisted in-progress tasks after startup/resume. */
private syncTrackedTasks(tasks = this.store.list()) {
for (const task of tasks) {
if (task.status === "in_progress" && !this.metrics.has(task.id)) {
const existingStats = isTaskExecutionStats(task.metadata?.executionStats)
? task.metadata.executionStats
: undefined;
const startedAt = existingStats?.startedAt ?? task.updatedAt;
this.metrics.set(task.id, {
startedAt,
inputTokens: existingStats?.inputTokens ?? 0,
outputTokens: existingStats?.outputTokens ?? 0,
});
if (!existingStats) {
this.persistStartMetrics(task.id, startedAt);
}
}
}

for (const [id] of this.metrics) {
const task = tasks.find(t => t.id === id) ?? this.store.get(id);
if (!task) {
this.activeTaskIds.delete(id);
this.metrics.delete(id);
continue;
}
if (task.status !== "in_progress") {
this.activeTaskIds.delete(id);
this.persistMetrics(id, task);
}
}

for (const task of tasks) {
if (task.status === "completed" && !isCompletedTaskExecutionStats(task.metadata?.executionStats)) {
this.store.update(task.id, { metadata: { executionStats: this.inferCompletedStats(task) } });
}
}
}

/** Add or remove a task from the active spinner set. */
setActiveTask(taskId: string | undefined, active = true) {
if (taskId && active) {
this.activeTaskIds.add(taskId);
const task = this.store.get(taskId);
const existingStats = isTaskExecutionStats(task?.metadata?.executionStats)
? task.metadata.executionStats
: undefined;
if (!this.metrics.has(taskId)) {
this.metrics.set(taskId, { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 });
const startedAt = existingStats?.startedAt ?? Date.now();
this.metrics.set(taskId, {
startedAt,
inputTokens: existingStats?.inputTokens ?? 0,
outputTokens: existingStats?.outputTokens ?? 0,
});
if (!existingStats) {
this.persistStartMetrics(taskId, startedAt);
}
}
this.ensureTimer();
} else if (taskId) {
this.activeTaskIds.delete(taskId);
const task = this.store.get(taskId);
this.persistMetrics(taskId, task);
}
this.update();
}
Expand Down Expand Up @@ -200,25 +340,31 @@ export class TaskWidget {
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})`)}`;
}
const stats = formatLiveStats(theme, this.metrics.get(task.id));
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))}`;
const stats = isCompletedTaskExecutionStats(task.metadata.executionStats)
? task.metadata.executionStats
: undefined;
const statParts = stats
? [
`started ${formatClockTime(stats.startedAt)}`,
`ended ${formatClockTime(stats.completedAt)}`,
formatDuration(stats.durationMs),
...(stats.inputTokens > 0 ? [`↑ ${formatTokens(stats.inputTokens)}`] : []),
...(stats.outputTokens > 0 ? [`↓ ${formatTokens(stats.outputTokens)}`] : []),
]
: [];
const statSuffix = statParts.length > 0 ? ` ${theme.fg("dim", `(${statParts.join(" · ")})`)}` : "";
text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}${statSuffix}`;
} else {
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
: "";
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}`;
const stats = task.status === "in_progress"
? formatLiveStats(theme, this.metrics.get(task.id))
: "";
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${stats}`;
}

lines.push(truncate(text + suffix));
Expand All @@ -235,6 +381,7 @@ export class TaskWidget {
update() {
if (!this.uiCtx) return;
const tasks = this.store.list();
this.syncTrackedTasks(tasks);

// Transition: visible → hidden
if (tasks.length === 0) {
Expand All @@ -249,15 +396,6 @@ export class TaskWidget {
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) {
Expand Down
Loading