diff --git a/README.md b/README.md index b89bc72..7210090 100644 --- a/README.md +++ b/README.md @@ -55,16 +55,20 @@ The extension renders a persistent widget above the editor: ### Widget display settings -How tasks are sorted and how many are shown can be configured via `/tasks` → Settings (saved as project overrides in `.pi/tasks-config.json`). All defaults preserve the original behaviour. +How tasks are sorted and how many are shown can be configured via `/tasks` → Settings (saved as project overrides in `.pi/tasks-config.json`). | Setting | Values | Default | Behaviour | |---------|--------|---------|-----------| | `sortOrder` | `id` / `status` / `recent` / `oldest` | `id` | `id` = creation order; `status` groups completed → in-progress → pending; `recent`/`oldest` = by last-updated time | -| `maxVisible` | `5`–`100` | `10` | Caps how many task lines the widget shows (ignored when `showAll` is on) | -| `showAll` | `true` / `false` | `false` | When `true`, every task is shown regardless of `maxVisible` | -| `hiddenAt` | `bottom` / `top` | `bottom` | When the list overflows `maxVisible`, where the `… and N more` collapse happens. `top` pairs well with `sortOrder: status` to keep active work visible and fold completed tasks away | +| `maxVisible` | `5`–`100` | `10` | Window size when the global tool view is collapsed and `showAll` is off | +| `showAll` | `true` / `false` | `false` | When `true`, every task is always shown regardless of `maxVisible` | +| `hiddenAt` | `bottom` / `top` | `bottom` | Retained for configuration compatibility; focused overflow windows use separate earlier/later markers | -> Note: the widget's `status` order is completed-first (so finished work collapses at the top with `hiddenAt: top`), which is the reverse of the `TaskList` tool's pending-first order. +When a collapsed list exceeds `maxVisible`, the widget normally starts at the first unfinished task in the configured sort order. If fewer than `maxVisible` tasks remain, it backfills from earlier tasks so the window stays full. Separate `… N earlier` and `… N later` lines show hidden work on either side. If every task is complete, the final window is shown. Lists that fit within the limit remain fully visible. + +The task widget follows Pi's global tool expansion state (Ctrl-O by default): expand to show every task, then collapse to restore the focused window. The extension does not register or override that keybinding. + +> Note: the widget's `status` order is completed-first, which is the reverse of the `TaskList` tool's pending-first order. ## Tools diff --git a/src/ui/settings-menu.ts b/src/ui/settings-menu.ts index 6ea14ac..350c6b1 100644 --- a/src/ui/settings-menu.ts +++ b/src/ui/settings-menu.ts @@ -53,8 +53,8 @@ export async function openSettingsMenu( id: "showAll", label: "Show all tasks in widget", description: - "When ON, every task is shown regardless of the visible limit. " + - "When OFF, the list is capped by 'Max visible tasks'.", + "When ON, every task is always shown. " + + "When OFF, the widget uses a focused window unless Pi's global tool view is expanded.", currentValue: (cfg.showAll ?? false) ? "on" : "off", values: ["on", "off"], }, @@ -62,8 +62,8 @@ export async function openSettingsMenu( id: "maxVisible", label: "Max visible tasks in widget", description: - "Only applies when 'Show all tasks' is OFF. " + - "Caps how many task lines the widget shows.", + "Only applies when 'Show all tasks' is OFF and Pi's global tool view is collapsed. " + + "Sets the focused window size, normally starting at the first unfinished task and backfilling near the end.", currentValue: String(cfg.maxVisible ?? 10), values: ["5", "10", "15", "20", "30", "50", "100"], }, @@ -80,8 +80,8 @@ export async function openSettingsMenu( id: "hiddenAt", label: "Hidden tasks position", description: - '"bottom" hides tasks from the end of the list. ' + - '"top" hides tasks from the start (useful with status sort to collapse completed tasks).', + "Legacy compatibility setting. The focused window now shows separate earlier/later markers " + + "and follows Pi's global tool expansion state.", currentValue: cfg.hiddenAt ?? "bottom", values: ["bottom", "top"], }, diff --git a/src/ui/task-widget.ts b/src/ui/task-widget.ts index 69bbbf7..6a9a09f 100644 --- a/src/ui/task-widget.ts +++ b/src/ui/task-widget.ts @@ -12,20 +12,6 @@ import { truncateToWidth } from "@earendil-works/pi-tui"; import type { TaskStore } from "../task-store.js"; import type { TasksConfig } from "../tasks-config.js"; -// ---- Truncation ---- - -import type { Task } from "../types.js"; - -function truncateFromTop(tasks: Task[], limit: number): Task[] { - return tasks.slice(-limit); -} - -function truncateFromBottom(tasks: Task[], limit: number): Task[] { - return tasks.slice(0, limit); -} - -const TRUNCATE_FNS = { top: truncateFromTop, bottom: truncateFromBottom }; - // ---- Types ---- export type Theme = { @@ -41,6 +27,7 @@ export type UICtx = { content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }), options?: { placement?: "aboveEditor" | "belowEditor" }, ): void; + getToolsExpanded?(): boolean; }; /** Star spinner frames for animated active task indicator (matches Claude Code). */ @@ -98,6 +85,13 @@ export class TaskWidget { } setUICtx(ctx: UICtx) { + if (this.uiCtx === ctx) return; + + if (this.uiCtx && this.widgetRegistered) { + this.uiCtx.setWidget("tasks", undefined); + } + this.widgetRegistered = false; + this.tui = undefined; this.uiCtx = ctx; } @@ -139,14 +133,14 @@ export class TaskWidget { * empty for one frame. */ private renderWidget(tui: any, theme: Theme): string[] { try { - return this.buildWidgetLines(tui, theme); + return this.buildWidgetLines(tui, theme, this.uiCtx?.getToolsExpanded?.() ?? false); } catch { return []; } } /** Build widget lines from current live state. */ - private buildWidgetLines(tui: any, theme: Theme): string[] { + private buildWidgetLines(tui: any, theme: Theme, toolsExpanded = false): string[] { const sortOrder = this.config.sortOrder ?? "id"; const tasks = this.store.list(sortOrder); const w = tui.terminal.columns; @@ -167,18 +161,20 @@ export class TaskWidget { const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length]; const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))]; - const showAll = this.config.showAll ?? false; + const showAll = toolsExpanded || (this.config.showAll ?? false); const limit = this.config.maxVisible ?? DEFAULT_MAX_VISIBLE_TASKS; - const hiddenAt = this.config.hiddenAt ?? "bottom"; - const visible = showAll ? tasks : TRUNCATE_FNS[hiddenAt](tasks, limit); - - const hiddenCount = tasks.length - visible.length; - const overflowLine = hiddenCount > 0 - ? truncate(theme.fg("dim", ` … and ${hiddenCount} more`)) - : undefined; - - if (overflowLine && hiddenAt === "top") { - lines.push(overflowLine); + const focusIndex = tasks.findIndex(task => task.status !== "completed"); + const windowStart = Math.min( + focusIndex === -1 ? tasks.length : focusIndex, + tasks.length - limit, + ); + const shouldWindow = !showAll && tasks.length > limit; + const visible = shouldWindow ? tasks.slice(windowStart, windowStart + limit) : tasks; + const hiddenBefore = shouldWindow ? windowStart : 0; + const hiddenAfter = shouldWindow ? tasks.length - windowStart - visible.length : 0; + + if (hiddenBefore > 0) { + lines.push(truncate(theme.fg("dim", ` … ${hiddenBefore} earlier`))); } for (let i = 0; i < visible.length; i++) { const task = visible[i]; @@ -235,8 +231,8 @@ export class TaskWidget { lines.push(truncate(text + suffix)); } - if (overflowLine && hiddenAt !== "top") { - lines.push(overflowLine); + if (hiddenAfter > 0) { + lines.push(truncate(theme.fg("dim", ` … ${hiddenAfter} later`))); } return lines; diff --git a/test/task-widget-ui-context.test.ts b/test/task-widget-ui-context.test.ts new file mode 100644 index 0000000..a3794f6 --- /dev/null +++ b/test/task-widget-ui-context.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskStore } from "../src/task-store.js"; +import { TaskWidget, type Theme, type UICtx } from "../src/ui/task-widget.js"; + +/** Create a mock theme that returns raw text (no ANSI escapes). */ +function mockTheme(): Theme { + return { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + strikethrough: (text: string) => `~~${text}~~`, + }; +} + +type MockUI = ReturnType; + +/** Create a mock UICtx that exposes only observable widget registration behavior. */ +function mockUICtx(toolsExpanded = false) { + const requestRender = vi.fn(); + const state: { + widgets: Map; + statuses: Map; + toolsExpanded: boolean; + } = { + widgets: new Map(), + statuses: new Map(), + toolsExpanded, + }; + const setWidget = vi.fn((key: string, content: any, options?: { placement?: "aboveEditor" | "belowEditor" }) => { + state.widgets.set(key, { content, options }); + }); + + const ctx: UICtx = { + setWidget, + setStatus(key, text) { + state.statuses.set(key, text); + }, + getToolsExpanded() { + return state.toolsExpanded; + }, + }; + + return { ctx, state, requestRender, setWidget }; +} + +/** Render the currently registered widget and return its lines. */ +function renderWidget(ui: MockUI): string[] { + const entry = ui.state.widgets.get("tasks"); + if (!entry?.content) return []; + const tui = { terminal: { columns: 200 }, requestRender: ui.requestRender }; + return entry.content(tui, mockTheme()).render(); +} + +function activeIcon(lines: string[]): string { + return lines.slice(1).map(line => line.trimStart().split(" ")[0])[0] ?? ""; +} + +describe("TaskWidget UI context handoff", () => { + let store: TaskStore; + let widget: TaskWidget; + + beforeEach(() => { + vi.useFakeTimers(); + store = new TaskStore(); + widget = new TaskWidget(store, { maxVisible: 3 }); + }); + + afterEach(() => { + widget.dispose(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("does not re-register or lose the cached TUI for the same context", () => { + const ui = mockUICtx(); + widget.setUICtx(ui.ctx); + store.create("Task", "Desc"); + widget.update(); + renderWidget(ui); // Cache the TUI used by the registered widget. + + ui.setWidget.mockClear(); + ui.requestRender.mockClear(); + widget.setUICtx(ui.ctx); + + expect(ui.setWidget).not.toHaveBeenCalled(); + widget.update(); + expect(ui.setWidget).not.toHaveBeenCalled(); + expect(ui.requestRender).toHaveBeenCalledTimes(1); + }); + + it("hands an active widget to a new context without losing state or rendering stale UI", () => { + const firstUI = mockUICtx(false); + widget.setUICtx(firstUI.ctx); + for (let id = 1; id <= 5; id++) store.create(`Task ${id}`, "Desc"); + store.update("1", { status: "in_progress" }); + widget.setActiveTask("1", true); + widget.addTokenUsage(1200, 300); + + const initialFrame = activeIcon(renderWidget(firstUI)); + vi.advanceTimersByTime(1000); + const frameBeforeSwitch = activeIcon(renderWidget(firstUI)); + expect(frameBeforeSwitch).not.toBe(initialFrame); + expect(renderWidget(firstUI)[1]).toContain("↑ 1.2k"); + expect(renderWidget(firstUI)[1]).toContain("↓ 300"); + + firstUI.requestRender.mockClear(); + const secondUI = mockUICtx(true); + widget.setUICtx(secondUI.ctx); + + expect(firstUI.state.widgets.get("tasks")?.content).toBeUndefined(); + expect(firstUI.setWidget).toHaveBeenLastCalledWith("tasks", undefined); + expect(secondUI.setWidget).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(1); + + // Re-register in the new context without resetting animation or metrics. + widget.update(); + expect(secondUI.setWidget).toHaveBeenCalledTimes(1); + const expandedLines = renderWidget(secondUI); + expect(expandedLines).toHaveLength(6); // header + all five tasks + expect(activeIcon(expandedLines)).not.toBe(initialFrame); + expect(expandedLines[1]).toContain("↑ 1.2k"); + expect(expandedLines[1]).toContain("↓ 300"); + expect(expandedLines.join("\n")).not.toContain("earlier"); + expect(expandedLines.join("\n")).not.toContain("later"); + + // Timer-driven renders use the new TUI, never the stale old one. + secondUI.requestRender.mockClear(); + vi.advanceTimersByTime(150); + expect(firstUI.requestRender).not.toHaveBeenCalled(); + expect(secondUI.requestRender).toHaveBeenCalled(); + const postTickLines = renderWidget(secondUI); + expect(activeIcon(postTickLines)).not.toBe(frameBeforeSwitch); + expect(postTickLines[1]).toContain("1s"); + expect(postTickLines[1]).toContain("↑ 1.2k"); + expect(postTickLines[1]).toContain("↓ 300"); + + // getToolsExpanded is read at render time, so collapse returns to the focused window. + secondUI.state.toolsExpanded = false; + expect(renderWidget(secondUI)).toHaveLength(5); // header + three tasks + later marker + expect(vi.getTimerCount()).toBe(1); + + widget.dispose(); + expect(secondUI.state.widgets.get("tasks")?.content).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/test/task-widget.test.ts b/test/task-widget.test.ts index 3272bac..32c2388 100644 --- a/test/task-widget.test.ts +++ b/test/task-widget.test.ts @@ -173,44 +173,67 @@ describe("TaskWidget", () => { expect(ui.state.widgets.get("tasks")?.content).toBeUndefined(); }); - it("limits visible tasks to MAX_VISIBLE_TASKS", () => { - for (let i = 0; i < 15; i++) { - store.create(`Task ${i + 1}`, "Desc"); - } + it("shows the default window from the first unfinished task", () => { + for (let i = 1; i <= 15; i++) store.create(`Task ${i}`, "Desc"); + for (let i = 1; i <= 3; i++) store.update(String(i), { status: "completed" }); widget.update(); const lines = renderWidget(ui.state); - // header + 10 tasks + "… and 5 more" - expect(lines).toHaveLength(12); - expect(lines[11]).toContain("5 more"); + expect(lines).toHaveLength(13); // header + top overflow + 10 tasks + bottom overflow + expect(lines[1]).toContain("3 earlier"); + expect(lines[2]).toContain("Task 4"); + expect(lines[11]).toContain("Task 13"); + expect(lines[12]).toContain("2 later"); + expect(lines.some(l => l.includes("Task 3"))).toBe(false); + expect(lines.some(l => l.includes("Task 14"))).toBe(false); }); - it("respects maxVisible config", () => { + it("respects maxVisible config for the unfinished-task window", () => { widget = new TaskWidget(store, { maxVisible: 5 }); widget.setUICtx(ui.ctx); - for (let i = 0; i < 15; i++) { - store.create(`Task ${i + 1}`, "Desc"); - } + for (let i = 1; i <= 12; i++) store.create(`Task ${i}`, "Desc"); + for (let i = 1; i <= 4; i++) store.update(String(i), { status: "completed" }); widget.update(); const lines = renderWidget(ui.state); - // header + 5 tasks + "… and 10 more" - expect(lines).toHaveLength(7); - expect(lines[6]).toContain("10 more"); + expect(lines).toHaveLength(8); // header + two overflow markers + 5 tasks + expect(lines[1]).toContain("4 earlier"); + expect(lines[2]).toContain("Task 5"); + expect(lines[6]).toContain("Task 9"); + expect(lines[7]).toContain("3 later"); }); - it("shows all tasks when limit exceeds task count", () => { + it("shifts the window backward when the unfinished task is near the end", () => { + widget = new TaskWidget(store, { maxVisible: 5 }); + widget.setUICtx(ui.ctx); + for (let i = 1; i <= 12; i++) store.create(`Task ${i}`, "Desc"); + for (let i = 1; i <= 9; i++) store.update(String(i), { status: "completed" }); + widget.update(); + + const lines = renderWidget(ui.state); + expect(lines).toHaveLength(7); // header + earlier marker + 5 tasks + expect(lines[1]).toContain("7 earlier"); + expect(lines[2]).toContain("Task 8"); + expect(lines[6]).toContain("Task 12"); + expect(lines.some(l => l.includes("Task 7"))).toBe(false); + expect(lines.some(l => l.includes("later"))).toBe(false); + }); + + it("shows every task when a mixed-status list fits within the limit", () => { widget = new TaskWidget(store, { maxVisible: 10 }); widget.setUICtx(ui.ctx); for (let i = 0; i < 3; i++) { store.create(`Task ${i + 1}`, "Desc"); } + store.update("1", { status: "completed" }); widget.update(); const lines = renderWidget(ui.state); - // header + 3 tasks, no overflow expect(lines).toHaveLength(4); - expect(lines[lines.length - 1]).not.toContain("more"); + expect(lines[1]).toContain("Task 1"); + expect(lines[3]).toContain("Task 3"); + expect(lines.join("\n")).not.toContain("earlier"); + expect(lines.join("\n")).not.toContain("later"); }); it("shows all tasks when showAll is true even with maxVisible set", () => { @@ -222,54 +245,48 @@ describe("TaskWidget", () => { widget.update(); const lines = renderWidget(ui.state); - // header + 15 tasks, no overflow line expect(lines).toHaveLength(16); - expect(lines[lines.length - 1]).not.toContain("more"); + expect(lines.join("\n")).not.toContain("earlier"); + expect(lines.join("\n")).not.toContain("later"); }); - it("truncates from top when hiddenAt is 'top'", () => { - widget = new TaskWidget(store, { sortOrder: "status", hiddenAt: "top", showAll: false, maxVisible: 5 }); + it("shows the final window when every task is completed", () => { + widget = new TaskWidget(store, { maxVisible: 3 }); widget.setUICtx(ui.ctx); - // 4 completed, 2 in_progress, 2 pending = 8 total, limit 5 - for (let i = 1; i <= 4; i++) store.create(`Done ${i}`, "Desc"); - for (let i = 1; i <= 2; i++) store.create(`Working ${i}`, "Desc"); - for (let i = 1; i <= 2; i++) store.create(`Todo ${i}`, "Desc"); - for (let i = 1; i <= 4; i++) store.update(String(i), { status: "completed" }); - for (let i = 5; i <= 6; i++) store.update(String(i), { status: "in_progress" }); + for (let i = 1; i <= 5; i++) { + store.create(`Task ${i}`, "Desc"); + store.update(String(i), { status: "completed" }); + } widget.update(); const lines = renderWidget(ui.state); - // header + overflow line + 5 visible = 7 lines - expect(lines).toHaveLength(7); - // overflow at top (after header) - expect(lines[1]).toContain("3 more"); - // all in_progress and pending visible - expect(lines.some(l => l.includes("Working 1"))).toBe(true); - expect(lines.some(l => l.includes("Todo 2"))).toBe(true); - // only newest completed (#4) visible - expect(lines.some(l => l.includes("Done 4"))).toBe(true); - // oldest completed hidden - expect(lines.some(l => l.includes("Done 1"))).toBe(false); - expect(lines.some(l => l.includes("Done 3"))).toBe(false); - }); - - it("truncates from bottom by default", () => { - widget = new TaskWidget(store, { maxVisible: 3 }); + expect(lines).toHaveLength(5); // header + top overflow + 3 completed tasks + expect(lines[1]).toContain("2 earlier"); + expect(lines[2]).toContain("Task 3"); + expect(lines[4]).toContain("Task 5"); + expect(lines.some(l => l.includes("later"))).toBe(false); + }); + + it("uses the configured sort order before locating the first unfinished task", () => { + widget = new TaskWidget(store, { sortOrder: "status", hiddenAt: "top", maxVisible: 2 }); widget.setUICtx(ui.ctx); - for (let i = 1; i <= 5; i++) store.create(`Task ${i}`, "Desc"); + store.create("Pending task", "Desc"); + store.create("Completed task", "Desc"); + store.create("In progress task", "Desc"); + store.update("2", { status: "completed" }); + store.update("3", { status: "in_progress" }); widget.update(); const lines = renderWidget(ui.state); - // header + 3 tasks + overflow at bottom = 5 lines - expect(lines).toHaveLength(5); - expect(lines[1]).toContain("Task 1"); - expect(lines[3]).toContain("Task 3"); - expect(lines[4]).toContain("2 more"); - expect(lines.some(l => l.includes("Task 4"))).toBe(false); + expect(lines[1]).toContain("1 earlier"); + expect(lines[2]).toContain("In progress task"); + expect(lines[3]).toContain("Pending task"); + expect(lines.some(l => l.includes("Completed task"))).toBe(false); + expect(lines.some(l => l.includes("later"))).toBe(false); }); it("sorts tasks by status when sortOrder is 'status'", () => { - widget = new TaskWidget(store, { sortOrder: "status" }); + widget = new TaskWidget(store, { sortOrder: "status", showAll: true }); widget.setUICtx(ui.ctx); store.create("Pending task", "Desc"); // #1 store.create("Completed task", "Desc"); // #2