mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-09-11 12:43:24 +08:00
Collapse task widget to one inline line to reclaim bottom space
Goal markers (◻/◼/spinner) render inline before the count title instead of a title line + one row per task (was up to 7 lines). Active task sorts first so its progress label always shows. Truncation is now ANSI-aware so the single, longer line never gets cut mid-escape. Drops the unused token tracking (addTokenUsage had no caller) and the inline blocked-by suffix. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
+56
-102
@@ -1,20 +1,39 @@
|
||||
/**
|
||||
* task-widget.ts — Persistent widget showing open goals with simple status icons and progress.
|
||||
* task-widget.ts — Persistent one-line widget showing open goals inline.
|
||||
*
|
||||
* Display style:
|
||||
* ◼ in_progress tasks
|
||||
* ◻ pending tasks
|
||||
* ✳/✽ actively executing task (star spinner with progress_label text)
|
||||
* Completed tasks stay in storage but are hidden from the collapsed widget.
|
||||
* Single line, goal markers first then a short count, e.g.
|
||||
* ◼#12 fix auth ◻#13 add tests ✳#14 deploying… · 3 goals (1 in progress)
|
||||
* Kept to one line to stay out of the way (pi widgets eat bottom space).
|
||||
*
|
||||
* Markers:
|
||||
* ◼ in_progress ◻ pending ✳/✽ actively executing (star spinner + progress_label)
|
||||
* Completed tasks stay in storage but are hidden here.
|
||||
*/
|
||||
|
||||
import type { Task } from "../types.js";
|
||||
import type { TaskStore } from "../task-store.js";
|
||||
import type { Task } from "../types.js";
|
||||
|
||||
// Simple truncation fallback
|
||||
// ANSI-aware truncation: count only visible chars, never cut inside an escape
|
||||
// sequence (which would corrupt the terminal), and reset color at the cut.
|
||||
const ANSI = /\x1b\[[0-9;]*m/y;
|
||||
function truncateToWidth(line: string, maxWidth: number): string {
|
||||
if (line.length <= maxWidth) return line;
|
||||
return line.slice(0, maxWidth - 1) + "…";
|
||||
let visible = 0;
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
ANSI.lastIndex = i;
|
||||
const m = ANSI.exec(line);
|
||||
if (m) {
|
||||
out += m[0];
|
||||
i = ANSI.lastIndex;
|
||||
continue;
|
||||
}
|
||||
if (visible >= maxWidth - 1) return out + "…\x1b[0m";
|
||||
out += line[i];
|
||||
visible++;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getDisplayStatus(task: Task): "in_progress" | "pending" | "completed" {
|
||||
@@ -48,11 +67,9 @@ const SPINNER = ["✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻",
|
||||
|
||||
const MAX_VISIBLE_TASKS = 5;
|
||||
|
||||
/** Per-task runtime metrics (elapsed time, token usage). */
|
||||
/** Per-task runtime metrics (elapsed time). */
|
||||
export interface TaskMetrics {
|
||||
startedAt: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
/** Format milliseconds as a human-readable duration (e.g., "2m 49s", "1h 3m"). */
|
||||
@@ -67,12 +84,6 @@ function formatDuration(ms: number): string {
|
||||
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 {
|
||||
@@ -103,11 +114,7 @@ export class TaskWidget {
|
||||
if (taskId && active) {
|
||||
this.activeTaskIds.add(taskId);
|
||||
if (!this.metrics.has(taskId)) {
|
||||
this.metrics.set(taskId, {
|
||||
startedAt: Date.now(),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
this.metrics.set(taskId, { startedAt: Date.now() });
|
||||
}
|
||||
this.ensureTimer();
|
||||
} else if (taskId) {
|
||||
@@ -116,18 +123,6 @@ export class TaskWidget {
|
||||
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) {
|
||||
@@ -135,11 +130,10 @@ export class TaskWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build widget lines from current live state. Called from the render callback. */
|
||||
/** Build the single widget line 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 [];
|
||||
|
||||
@@ -149,83 +143,43 @@ export class TaskWidget {
|
||||
const visibleTasks = tasks.filter((task) => task.status !== "completed");
|
||||
if (visibleTasks.length === 0) return [];
|
||||
|
||||
const parts: string[] = [];
|
||||
if (counts.completed > 0) parts.push(`${counts.completed} done hidden`);
|
||||
if (counts.in_progress > 0) parts.push(`${counts.in_progress} in progress`);
|
||||
if (counts.pending > 0) parts.push(`${counts.pending} open`);
|
||||
const statusText = `${tasks.length} goals (${parts.join(", ")})`;
|
||||
|
||||
// Goal markers inline, active task first so its progress always shows.
|
||||
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
||||
const lines: string[] = [
|
||||
truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText)),
|
||||
];
|
||||
const ordered = [...visibleTasks].sort((a, b) => {
|
||||
const aActive = this.activeTaskIds.has(a.id) && a.status === "in_progress" ? 0 : 1;
|
||||
const bActive = this.activeTaskIds.has(b.id) && b.status === "in_progress" ? 0 : 1;
|
||||
return aActive - bActive;
|
||||
});
|
||||
|
||||
const visible = visibleTasks.slice(0, MAX_VISIBLE_TASKS);
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const task = visible[i];
|
||||
const markers: string[] = [];
|
||||
for (const task of ordered.slice(0, MAX_VISIBLE_TASKS)) {
|
||||
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 === "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;
|
||||
const id = theme.fg("dim", "#" + task.id);
|
||||
if (isActive) {
|
||||
const form = task.progress_label || task.subject;
|
||||
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 + "…")}${stats}`;
|
||||
const elapsed = m ? ` ${theme.fg("dim", `(${formatDuration(Date.now() - m.startedAt)})`)}` : "";
|
||||
markers.push(`${theme.fg("accent", spinnerChar)}${id} ${theme.fg("accent", form + "…")}${elapsed}`);
|
||||
} else if (task.status === "in_progress") {
|
||||
markers.push(`${theme.fg("accent", "◼")}${id} ${task.subject}`);
|
||||
} else {
|
||||
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}`;
|
||||
markers.push(`◻${id} ${task.subject}`);
|
||||
}
|
||||
|
||||
lines.push(truncate(text + suffix));
|
||||
}
|
||||
|
||||
if (visibleTasks.length > MAX_VISIBLE_TASKS) {
|
||||
lines.push(
|
||||
truncate(
|
||||
theme.fg(
|
||||
"dim",
|
||||
` … and ${visibleTasks.length - MAX_VISIBLE_TASKS} more open`,
|
||||
),
|
||||
),
|
||||
);
|
||||
markers.push(theme.fg("dim", `+${visibleTasks.length - MAX_VISIBLE_TASKS} more`));
|
||||
}
|
||||
|
||||
return lines;
|
||||
// Short count title, after the markers.
|
||||
const parts: string[] = [];
|
||||
if (counts.in_progress > 0) parts.push(`${counts.in_progress} in progress`);
|
||||
if (counts.pending > 0) parts.push(`${counts.pending} open`);
|
||||
if (counts.completed > 0) parts.push(`${counts.completed} done`);
|
||||
const title = theme.fg("accent", `${tasks.length} goals (${parts.join(", ")})`);
|
||||
|
||||
const line = markers.join(" ") + theme.fg("dim", " · ") + title;
|
||||
return [truncateToWidth(line, w)];
|
||||
}
|
||||
|
||||
/** Force an immediate widget update. */
|
||||
|
||||
+60
-190
@@ -33,7 +33,7 @@ function mockUICtx() {
|
||||
return { ctx, state };
|
||||
}
|
||||
|
||||
/** Render the widget and return its lines. */
|
||||
/** Render the widget and return its lines (the widget is a single line). */
|
||||
function renderWidget(state: ReturnType<typeof mockUICtx>["state"]): string[] {
|
||||
const entry = state.widgets.get("tasks");
|
||||
if (!entry?.content) return [];
|
||||
@@ -43,6 +43,12 @@ function renderWidget(state: ReturnType<typeof mockUICtx>["state"]): string[] {
|
||||
return result.render();
|
||||
}
|
||||
|
||||
/** The single rendered line, or "" when the widget is hidden. */
|
||||
function line(state: ReturnType<typeof mockUICtx>["state"]): string {
|
||||
const lines = renderWidget(state);
|
||||
return lines[0] ?? "";
|
||||
}
|
||||
|
||||
describe("TaskWidget", () => {
|
||||
let store: TaskStore;
|
||||
let widget: TaskWidget;
|
||||
@@ -67,27 +73,28 @@ describe("TaskWidget", () => {
|
||||
expect(entry?.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders pending tasks with ◻ icon", () => {
|
||||
it("renders a single line with the goal marker before the count title", () => {
|
||||
store.create("Do something", "Desc", "done");
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines).toHaveLength(2); // header + 1 task
|
||||
expect(lines[0]).toContain("1 goals");
|
||||
expect(lines[0]).toContain("1 open");
|
||||
expect(lines[1]).toContain("◻");
|
||||
expect(lines[1]).toContain("Do something");
|
||||
expect(lines[1]).not.toContain("done");
|
||||
expect(lines).toHaveLength(1);
|
||||
const l = lines[0];
|
||||
// Marker comes before the title.
|
||||
expect(l.indexOf("Do something")).toBeLessThan(l.indexOf("1 goals"));
|
||||
expect(l).toContain("◻");
|
||||
expect(l).toContain("1 open");
|
||||
// done_criterion text is not shown inline.
|
||||
expect(l).not.toContain("done");
|
||||
});
|
||||
|
||||
it("renders in-progress tasks with ◼ icon", () => {
|
||||
it("renders in-progress tasks with ◼ marker", () => {
|
||||
store.create("Working on it", "Desc", "done");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("◼");
|
||||
expect(lines[1]).toContain("Working on it");
|
||||
expect(line(ui.state)).toContain("◼");
|
||||
expect(line(ui.state)).toContain("Working on it");
|
||||
});
|
||||
|
||||
it("hides the widget when only completed tasks remain", () => {
|
||||
@@ -95,11 +102,10 @@ describe("TaskWidget", () => {
|
||||
store.complete("1");
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines).toEqual([]);
|
||||
expect(renderWidget(ui.state)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not render proof badges on collapsed rows", () => {
|
||||
it("does not leak metadata into the line", () => {
|
||||
store.create("Open task", "Desc", "done");
|
||||
store.create("Done task", "Desc", "done");
|
||||
store.update("2", {
|
||||
@@ -111,51 +117,23 @@ describe("TaskWidget", () => {
|
||||
store.complete("2");
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("Open task");
|
||||
expect(lines[1]).not.toContain("[");
|
||||
expect(lines[1]).not.toContain("robot_review_observations");
|
||||
expect(lines[1]).not.toContain("lgtm_evidence");
|
||||
const l = line(ui.state);
|
||||
expect(l).toContain("Open task");
|
||||
expect(l).not.toContain("robot_review_observations");
|
||||
expect(l).not.toContain("lgtm_evidence");
|
||||
});
|
||||
|
||||
it("renders active tasks with spinner icon", () => {
|
||||
it("renders active tasks with progress label and no ◼", () => {
|
||||
store.create("Running thing", "Desc", "done criterion", "Processing data");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Should show activeForm text with "…" suffix
|
||||
expect(lines[1]).toContain("Processing data…");
|
||||
// Should NOT show ◼ for active task
|
||||
expect(lines[1]).not.toContain("◼");
|
||||
const l = line(ui.state);
|
||||
expect(l).toContain("Processing data…");
|
||||
expect(l).not.toContain("◼");
|
||||
});
|
||||
|
||||
it("shows blocked-by info for pending tasks", () => {
|
||||
store.create("Blocker", "Desc", "done");
|
||||
store.create("Blocked", "Desc", "done");
|
||||
store.update("2", { add_blocked_by: ["1"] });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const blockedLine = lines.find((l) => l.includes("Blocked"));
|
||||
// blocked-by suffix is only added via dim theme helper, which in mock is identity
|
||||
// So we should see the raw text. Check for the relevant subject line having blocked-by info
|
||||
expect(blockedLine).toContain("blocked by #1");
|
||||
});
|
||||
|
||||
it("hides completed blockers in blocked-by suffix", () => {
|
||||
store.create("Blocker", "Desc", "done");
|
||||
store.create("Blocked", "Desc", "done");
|
||||
store.update("2", { add_blocked_by: ["1"] });
|
||||
store.complete("1");
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const blockedLine = lines.find((l) => l.includes("Blocked"));
|
||||
expect(blockedLine).not.toContain("blocked by");
|
||||
});
|
||||
|
||||
it("shows status summary in header", () => {
|
||||
it("shows status summary in the count title", () => {
|
||||
store.create("Task A", "Desc", "done");
|
||||
store.create("Task B", "Desc", "done");
|
||||
store.create("Task C", "Desc", "done");
|
||||
@@ -163,11 +141,11 @@ describe("TaskWidget", () => {
|
||||
store.update("2", { status: "in_progress" });
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[0]).toContain("3 goals");
|
||||
expect(lines[0]).toContain("1 done hidden");
|
||||
expect(lines[0]).toContain("1 in progress");
|
||||
expect(lines[0]).toContain("1 open");
|
||||
const l = line(ui.state);
|
||||
expect(l).toContain("3 goals");
|
||||
expect(l).toContain("1 in progress");
|
||||
expect(l).toContain("1 open");
|
||||
expect(l).toContain("1 done");
|
||||
});
|
||||
|
||||
it("clears widget when all tasks are deleted", () => {
|
||||
@@ -180,30 +158,15 @@ describe("TaskWidget", () => {
|
||||
expect(ui.state.widgets.get("tasks")?.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("limits visible tasks to MAX_VISIBLE_TASKS", () => {
|
||||
it("collapses overflow past MAX_VISIBLE_TASKS into '+N more', still one line", () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
store.create(`Task ${i + 1}`, "Desc", "done");
|
||||
}
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// header + 5 visible tasks + "...and 10 more open"
|
||||
expect(lines).toHaveLength(7);
|
||||
expect(lines[6]).toContain("10 more open");
|
||||
});
|
||||
|
||||
it("tracks token usage for active tasks", () => {
|
||||
store.create("Active task", "Desc", "done criterion", "Running");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
widget.addTokenUsage(1000, 500);
|
||||
widget.addTokenUsage(500, 300);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const activeLine = lines.find((l) => l.includes("Running…"));
|
||||
expect(activeLine).toContain("↑ 1.5k");
|
||||
expect(activeLine).toContain("↓ 800");
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain("+10 more");
|
||||
});
|
||||
|
||||
it("deactivates a task with setActiveTask(id, false)", () => {
|
||||
@@ -211,15 +174,12 @@ describe("TaskWidget", () => {
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
// Should be active (spinner)
|
||||
let lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("Doing work…");
|
||||
expect(line(ui.state)).toContain("Doing work…");
|
||||
|
||||
widget.setActiveTask("1", false);
|
||||
lines = renderWidget(ui.state);
|
||||
// Should now show as regular in_progress (◼)
|
||||
expect(lines[1]).toContain("◼");
|
||||
expect(lines[1]).not.toContain("Doing work…");
|
||||
const l = line(ui.state);
|
||||
expect(l).toContain("◼");
|
||||
expect(l).not.toContain("Doing work…");
|
||||
});
|
||||
|
||||
it("prunes stale active IDs on update", () => {
|
||||
@@ -227,16 +187,13 @@ describe("TaskWidget", () => {
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
// Complete the task externally
|
||||
store.complete("1");
|
||||
widget.update();
|
||||
|
||||
// Completed tasks are hidden from the default widget
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines).toEqual([]);
|
||||
expect(renderWidget(ui.state)).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports multiple active tasks simultaneously", () => {
|
||||
it("supports multiple active tasks on the same line", () => {
|
||||
store.create("Task A", "Desc", "done criterion", "Processing A");
|
||||
store.create("Task B", "Desc", "done criterion", "Processing B");
|
||||
store.update("1", { status: "in_progress" });
|
||||
@@ -244,25 +201,9 @@ describe("TaskWidget", () => {
|
||||
widget.setActiveTask("1", true);
|
||||
widget.setActiveTask("2", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("Processing A…");
|
||||
expect(lines[2]).toContain("Processing B…");
|
||||
});
|
||||
|
||||
it("distributes token usage across all active tasks", () => {
|
||||
store.create("Task A", "Desc", "done criterion", "A");
|
||||
store.create("Task B", "Desc", "done criterion", "B");
|
||||
store.update("1", { status: "in_progress" });
|
||||
store.update("2", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
widget.setActiveTask("2", true);
|
||||
|
||||
widget.addTokenUsage(100, 50);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Both tasks should have the same token counts
|
||||
expect(lines[1]).toContain("↑ 100");
|
||||
expect(lines[2]).toContain("↑ 100");
|
||||
const l = line(ui.state);
|
||||
expect(l).toContain("Processing A…");
|
||||
expect(l).toContain("Processing B…");
|
||||
});
|
||||
|
||||
it("dispose clears widget and timer", () => {
|
||||
@@ -274,58 +215,12 @@ describe("TaskWidget", () => {
|
||||
expect(ui.state.widgets.get("tasks")?.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses subject as fallback when no activeForm", () => {
|
||||
it("uses subject as fallback when no progress_label", () => {
|
||||
store.create("My Subject", "Desc", "done");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("My Subject…");
|
||||
});
|
||||
|
||||
it("shows elapsed time but no token arrows when tokens are zero", () => {
|
||||
store.create("No tokens", "Desc", "done criterion", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
// No addTokenUsage calls — tokens stay at 0
|
||||
vi.advanceTimersByTime(5000);
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
const activeLine = lines.find((l) => l.includes("Working…"));
|
||||
expect(activeLine).toContain("5s");
|
||||
expect(activeLine).not.toContain("↑");
|
||||
expect(activeLine).not.toContain("↓");
|
||||
});
|
||||
|
||||
it("cleans up metrics when stale active IDs are pruned", () => {
|
||||
store.create("Task", "Desc", "done criterion", "Running");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
widget.addTokenUsage(100, 50);
|
||||
|
||||
// Delete task externally
|
||||
store.update("1", { status: "deleted" });
|
||||
widget.update();
|
||||
|
||||
// Reactivate with same ID (new task) — should get fresh metrics
|
||||
store.create("Task 2", "Desc", "done criterion", "Running"); // ID 2
|
||||
store.update("2", { status: "in_progress" });
|
||||
widget.setActiveTask("2", true);
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Should not carry over old tokens
|
||||
expect(lines[1]).not.toContain("↑ 100");
|
||||
});
|
||||
|
||||
it("indents task lines under header", () => {
|
||||
store.create("Indented task", "Desc", "done");
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
// Task line should start with 2 spaces
|
||||
expect(lines[1]).toMatch(/^\s{2}/);
|
||||
expect(line(ui.state)).toContain("My Subject…");
|
||||
});
|
||||
|
||||
it("widget is placed aboveEditor", () => {
|
||||
@@ -355,19 +250,23 @@ describe("formatDuration (via widget rendering)", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function activeLine(): string {
|
||||
const lines = renderWidget(ui.state);
|
||||
return lines[0] ?? "";
|
||||
}
|
||||
|
||||
it("shows seconds for short durations", () => {
|
||||
store.create("Quick", "Desc", "done criterion", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
vi.advanceTimersByTime(30_000); // 30s
|
||||
vi.advanceTimersByTime(30_000);
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("30s");
|
||||
expect(activeLine()).toContain("30s");
|
||||
});
|
||||
|
||||
it("shows hours for long durations", () => {
|
||||
it("shows hours and minutes for long durations", () => {
|
||||
store.create("Long", "Desc", "done criterion", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
@@ -375,8 +274,7 @@ describe("formatDuration (via widget rendering)", () => {
|
||||
vi.advanceTimersByTime(3_723_000); // 1h 2m 3s → "1h 2m"
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("1h 2m");
|
||||
expect(activeLine()).toContain("1h 2m");
|
||||
});
|
||||
|
||||
it("shows exact hours without minutes", () => {
|
||||
@@ -387,8 +285,7 @@ describe("formatDuration (via widget rendering)", () => {
|
||||
vi.advanceTimersByTime(7_200_000); // 2h exactly
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("2h)");
|
||||
expect(activeLine()).toContain("2h)");
|
||||
});
|
||||
|
||||
it("shows minutes and seconds", () => {
|
||||
@@ -399,33 +296,6 @@ describe("formatDuration (via widget rendering)", () => {
|
||||
vi.advanceTimersByTime(169_000); // 2m 49s
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("2m 49s");
|
||||
});
|
||||
|
||||
it("formats small token counts without k suffix", () => {
|
||||
store.create("Small", "Desc", "done criterion", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
widget.addTokenUsage(500, 200);
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("↑ 500");
|
||||
expect(lines[1]).toContain("↓ 200");
|
||||
});
|
||||
|
||||
it("formats token counts with k suffix and removes .0", () => {
|
||||
store.create("Large", "Desc", "done criterion", "Working");
|
||||
store.update("1", { status: "in_progress" });
|
||||
widget.setActiveTask("1", true);
|
||||
|
||||
widget.addTokenUsage(2000, 4100);
|
||||
widget.update();
|
||||
|
||||
const lines = renderWidget(ui.state);
|
||||
expect(lines[1]).toContain("↑ 2k"); // 2000 → "2k" (not "2.0k")
|
||||
expect(lines[1]).toContain("↓ 4.1k"); // 4100 → "4.1k"
|
||||
expect(activeLine()).toContain("2m 49s");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user