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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions src/ui/settings-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,17 @@ 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"],
},
{
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"],
},
Expand All @@ -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"],
},
Expand Down
54 changes: 25 additions & 29 deletions src/ui/task-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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). */
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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];
Expand Down Expand Up @@ -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;
Expand Down
145 changes: 145 additions & 0 deletions test/task-widget-ui-context.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mockUICtx>;

/** Create a mock UICtx that exposes only observable widget registration behavior. */
function mockUICtx(toolsExpanded = false) {
const requestRender = vi.fn();
const state: {
widgets: Map<string, any>;
statuses: Map<string, string | undefined>;
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);
});
});
Loading