From 00d9fe65c8ca207963139d4105bb955d24c65aee Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:52:18 +0800 Subject: [PATCH] Collapse task widget to one inline line to reclaim bottom space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/ui/task-widget.ts | 158 +++++++++---------------- test/task-widget.test.ts | 250 ++++++++++----------------------------- 2 files changed, 116 insertions(+), 292 deletions(-) diff --git a/src/ui/task-widget.ts b/src/ui/task-widget.ts index d647b0d..bb072fe 100644 --- a/src/ui/task-widget.ts +++ b/src/ui/task-widget.ts @@ -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. */ diff --git a/test/task-widget.test.ts b/test/task-widget.test.ts index d1156b8..44a2fb8 100644 --- a/test/task-widget.test.ts +++ b/test/task-widget.test.ts @@ -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["state"]): string[] { const entry = state.widgets.get("tasks"); if (!entry?.content) return []; @@ -43,6 +43,12 @@ function renderWidget(state: ReturnType["state"]): string[] { return result.render(); } +/** The single rendered line, or "" when the widget is hidden. */ +function line(state: ReturnType["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"); }); });