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:
wassname
2026-07-01 06:52:18 +08:00
co-authored by Claudypoo
parent ee04ba451e
commit 00d9fe65c8
2 changed files with 116 additions and 292 deletions
+56 -102
View File
@@ -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
View File
@@ -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");
});
});