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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ How tasks are sorted and how many are shown can be configured via `/tasks` → S
| 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 |
| `sortDirection` | `ascending` / `descending` | `ascending` | Descending reverses the selected sort order. With `status`, open tasks appear before completed tasks |
| `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 |
Expand Down Expand Up @@ -215,7 +216,7 @@ The `autoClearCompleted` setting controls automatic cleanup of completed tasks:

Both auto-clear modes use a turn-based delay for non-jarring UX — tasks linger briefly so you see the completion before they disappear.

Settings (`taskScope`, `autoCascade`, `autoClearCompleted`, plus the [widget display settings](#widget-display-settings) `sortOrder` / `maxVisible` / `showAll` / `hiddenAt`) are saved to `<cwd>/.pi/tasks-config.json`.
Settings (`taskScope`, `autoCascade`, `autoClearCompleted`, plus the [widget display settings](#widget-display-settings) `sortOrder` / `sortDirection` / `maxVisible` / `showAll` / `hiddenAt`) are saved to `<cwd>/.pi/tasks-config.json`.

### Override via environment variables

Expand Down
78 changes: 78 additions & 0 deletions src/task-sort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Task } from "./types.js";

export type TaskSortOrder = "id" | "status" | "recent" | "oldest";
export type TaskSortDirection = "ascending" | "descending";

function sortById(a: Task, b: Task): number {
return Number(a.id) - Number(b.id);
}

function sortByStatus(a: Task, b: Task): number {
const rank = (status: string) => status === "completed" ? 0 : status === "in_progress" ? 1 : 2;
return rank(a.status) - rank(b.status) || sortById(a, b);
}

function sortByRecent(a: Task, b: Task): number {
return b.updatedAt - a.updatedAt || Number(b.id) - Number(a.id);
}

function sortByOldest(a: Task, b: Task): number {
return a.updatedAt - b.updatedAt || sortById(a, b);
}

const SORT_FNS: Record<TaskSortOrder, (a: Task, b: Task) => number> = {
id: sortById,
status: sortByStatus,
recent: sortByRecent,
oldest: sortByOldest,
};

/** Return a sorted copy without mutating the input array. */
export function sortTasks(
tasks: readonly Task[],
sortOrder: TaskSortOrder = "id",
sortDirection: TaskSortDirection = "ascending",
): Task[] {
const sorted = [...tasks].sort(SORT_FNS[sortOrder]);
if (sortDirection === "descending") sorted.reverse();
return sorted;
}

type CacheEntry = { signature: string; orderedIds: string[] };

/** Memoizes ordering while returning the current task objects on every call. */
export class TaskSortCache {
private entries = new Map<string, CacheEntry>();

clear(): void {
this.entries.clear();
}

sort(
tasks: readonly Task[],
sortOrder: TaskSortOrder = "id",
sortDirection: TaskSortDirection = "ascending",
): Task[] {
const cacheKey = `${sortOrder}:${sortDirection}`;
const signature = JSON.stringify(tasks.map(task =>
sortOrder === "status"
? [task.id, task.status]
: sortOrder === "recent" || sortOrder === "oldest"
? [task.id, task.updatedAt]
: [task.id]));
let entry = this.entries.get(cacheKey);
if (!entry || entry.signature !== signature) {
entry = {
signature,
orderedIds: sortTasks(tasks, sortOrder, sortDirection).map(task => task.id),
};
this.entries.set(cacheKey, entry);
}

const currentTasks = new Map(tasks.map(task => [task.id, task]));
return entry.orderedIds.flatMap(id => {
const task = currentTasks.get(id);
return task ? [task] : [];
});
}
}
31 changes: 9 additions & 22 deletions src/task-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,9 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join } from "node:path";
import { sortTasks, type TaskSortOrder } from "./task-sort.js";
import type { Task, TaskStatus, TaskStoreData } from "./types.js";

function sortById(a: Task, b: Task): number {
return Number(a.id) - Number(b.id);
}

function sortByStatus(a: Task, b: Task): number {
const rank = (s: string) => s === "completed" ? 0 : s === "in_progress" ? 1 : 2;
return rank(a.status) - rank(b.status) || Number(a.id) - Number(b.id);
}

function sortByRecent(a: Task, b: Task): number {
return b.updatedAt - a.updatedAt || Number(b.id) - Number(a.id);
}

function sortByOldest(a: Task, b: Task): number {
return a.updatedAt - b.updatedAt || Number(a.id) - Number(b.id);
}

const SORT_FNS = { id: sortById, status: sortByStatus, recent: sortByRecent, oldest: sortByOldest };

const TASKS_DIR = join(homedir(), ".pi", "tasks");
const LOCK_RETRY_MS = 50;
const LOCK_MAX_RETRIES = 100; // 5s max
Expand Down Expand Up @@ -153,10 +135,15 @@ export class TaskStore {
return this.tasks.get(id);
}

/** List all tasks, sorted by the given order (defaults to ID ascending). */
list(sortOrder: "id" | "status" | "recent" | "oldest" = "id"): Task[] {
/** Return a fresh, unsorted snapshot of all tasks. */
snapshot(): Task[] {
if (this.filePath) this.load();
return Array.from(this.tasks.values()).sort(SORT_FNS[sortOrder]);
return Array.from(this.tasks.values());
}

/** List all tasks, sorted by the given order (defaults to ID ascending). */
list(sortOrder: TaskSortOrder = "id"): Task[] {
return sortTasks(this.snapshot(), sortOrder);
}

update(id: string, fields: {
Expand Down
4 changes: 3 additions & 1 deletion src/tasks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { TaskSortDirection, TaskSortOrder } from "./task-sort.js";

export interface TasksConfig {
taskScope?: "memory" | "session" | "project"; // default: "session"
autoCascade?: boolean; // default: false
autoClearCompleted?: "never" | "on_list_complete" | "on_task_complete"; // default: "on_list_complete"
showAll?: boolean; // default: false
maxVisible?: number; // default: 10
sortOrder?: "id" | "status" | "recent" | "oldest"; // default: "id"
sortOrder?: TaskSortOrder; // default: "id"
sortDirection?: TaskSortDirection; // default: "ascending"
hiddenAt?: "top" | "bottom"; // default: "bottom"
}

Expand Down
13 changes: 13 additions & 0 deletions src/ui/settings-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ export async function openSettingsMenu(
currentValue: cfg.sortOrder ?? "id",
values: ["id", "status", "recent", "oldest"],
},
{
id: "sortDirection",
label: "Widget sort direction",
description:
"Ascending uses the selected sort order; descending reverses it. " +
'With "status", descending puts open tasks before completed tasks.',
currentValue: cfg.sortDirection ?? "ascending",
values: ["ascending", "descending"],
},
{
id: "hiddenAt",
label: "Hidden tasks position",
Expand Down Expand Up @@ -127,6 +136,10 @@ export async function openSettingsMenu(
cfg.sortOrder = newValue as TasksConfig["sortOrder"];
saveTasksConfig(cfg);
}
if (id === "sortDirection") {
cfg.sortDirection = newValue as TasksConfig["sortDirection"];
saveTasksConfig(cfg);
}
if (id === "hiddenAt") {
cfg.hiddenAt = newValue as "top" | "bottom";
saveTasksConfig(cfg);
Expand Down
9 changes: 7 additions & 2 deletions src/ui/task-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { truncateToWidth } from "@earendil-works/pi-tui";
import { TaskSortCache } from "../task-sort.js";
import type { TaskStore } from "../task-store.js";
import type { TasksConfig } from "../tasks-config.js";

Expand Down Expand Up @@ -85,6 +86,8 @@ export class TaskWidget {
private metrics = new Map<string, TaskMetrics>();
/** Cached TUI instance for requestRender() calls. */
private tui: any | undefined;
/** Cached task ordering, kept separate from store persistence and mutation concerns. */
private taskSort = new TaskSortCache();
/** Whether the widget callback is currently registered. */
private widgetRegistered = false;

Expand All @@ -95,6 +98,7 @@ export class TaskWidget {

setStore(store: TaskStore) {
this.store = store;
this.taskSort.clear();
}

setUICtx(ctx: UICtx) {
Expand Down Expand Up @@ -137,7 +141,8 @@ export class TaskWidget {
/** Build widget lines from current live state. Called from the render callback. */
private renderWidget(tui: any, theme: Theme): string[] {
const sortOrder = this.config.sortOrder ?? "id";
const tasks = this.store.list(sortOrder);
const sortDirection = this.config.sortDirection ?? "ascending";
const tasks = this.taskSort.sort(this.store.snapshot(), sortOrder, sortDirection);
const w = tui.terminal.columns;
const truncate = (line: string) => truncateToWidth(line, w);

Expand Down Expand Up @@ -234,7 +239,7 @@ export class TaskWidget {
/** Force an immediate widget update. */
update() {
if (!this.uiCtx) return;
const tasks = this.store.list();
const tasks = this.store.snapshot();

// Transition: visible → hidden
if (tasks.length === 0) {
Expand Down
75 changes: 75 additions & 0 deletions test/task-sort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { sortTasks, TaskSortCache } from "../src/task-sort.js";
import type { Task, TaskStatus } from "../src/types.js";

function task(id: string, status: TaskStatus = "pending", updatedAt = Number(id)): Task {
return {
id,
subject: `Task ${id}`,
description: "Desc",
status,
metadata: {},
blocks: [],
blockedBy: [],
createdAt: Number(id),
updatedAt,
};
}

describe("task sorting", () => {
it("sorts a copy in either direction without mutating input", () => {
const input = [task("3"), task("1"), task("2")];

expect(sortTasks(input).map(item => item.id)).toEqual(["1", "2", "3"]);
expect(sortTasks(input, "id", "descending").map(item => item.id)).toEqual(["3", "2", "1"]);
expect(input.map(item => item.id)).toEqual(["3", "1", "2"]);
});

it("preserves existing status and update-time semantics", () => {
const input = [
task("1", "pending", 20),
task("2", "completed", 30),
task("3", "in_progress", 10),
];

expect(sortTasks(input, "status").map(item => item.id)).toEqual(["2", "3", "1"]);
expect(sortTasks(input, "recent").map(item => item.id)).toEqual(["2", "1", "3"]);
expect(sortTasks(input, "oldest").map(item => item.id)).toEqual(["3", "1", "2"]);
});

it("reuses cached ordering while sort keys are unchanged", () => {
const cache = new TaskSortCache();
const input = [task("3"), task("1"), task("2")];
const sortSpy = vi.spyOn(Array.prototype, "sort");
try {
cache.sort(input, "status", "descending");
const callsAfterFirstSort = sortSpy.mock.calls.length;
cache.sort(input, "status", "descending");
expect(sortSpy).toHaveBeenCalledTimes(callsAfterFirstSort);
} finally {
sortSpy.mockRestore();
}
});

it("re-sorts when a relevant key changes", () => {
const cache = new TaskSortCache();
const input = [task("1"), task("2")];
expect(cache.sort(input, "status").map(item => item.id)).toEqual(["1", "2"]);

input[1].status = "completed";

expect(cache.sort(input, "status").map(item => item.id)).toEqual(["2", "1"]);
});

it("returns current task objects when non-sort fields change", () => {
const cache = new TaskSortCache();
const initial = [task("2"), task("1")];
cache.sort(initial, "id");
const refreshed = initial.map(item => ({ ...item, subject: `Refreshed ${item.id}` }));

const sorted = cache.sort(refreshed, "id");

expect(sorted.map(item => item.subject)).toEqual(["Refreshed 1", "Refreshed 2"]);
expect(sorted[0]).toBe(refreshed[1]);
});
});
11 changes: 11 additions & 0 deletions test/task-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ describe("TaskStore (in-memory)", () => {
expect(tasks.map(t => t.id)).toEqual(["1", "2", "3"]);
});

it("keeps list behavior independent from widget sort caching", () => {
store.create("First", "Desc");
store.create("Second", "Desc");
const first = store.list("status");
first[1].status = "completed";
first.pop();

expect(store.list("status").map(task => task.id)).toEqual(["2", "1"]);
});

it("lists tasks sorted by status when sortOrder is 'status'", () => {
store.create("Pending", "Desc"); // #1
store.create("Completed", "Desc"); // #2
Expand Down Expand Up @@ -431,6 +441,7 @@ describe("TaskStore (file-backed)", () => {
const t3 = store2.create("Task 3", "Desc");
expect(t3.id).toBe("3");
});

});

describe("TaskStore (absolute path)", () => {
Expand Down
55 changes: 55 additions & 0 deletions test/task-widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,61 @@ describe("TaskWidget", () => {
expect(lines[3]).toContain("Pending task");
});

it("reverses status order so open tasks appear first", () => {
widget = new TaskWidget(store, { sortOrder: "status", sortDirection: "descending" });
widget.setUICtx(ui.ctx);
store.create("Pending task", "Desc"); // #1
store.create("Completed task", "Desc"); // #2
store.create("In progress task", "Desc"); // #3
store.update("2", { status: "completed" });
store.update("3", { status: "in_progress" });
widget.update();

const lines = renderWidget(ui.state);
// Reversed status order: pending, in_progress, completed
expect(lines[1]).toContain("Pending task");
expect(lines[2]).toContain("In progress task");
expect(lines[3]).toContain("Completed task");
});

it("reverses creation order when descending is selected", () => {
widget = new TaskWidget(store, { sortOrder: "id", sortDirection: "descending" });
widget.setUICtx(ui.ctx);
for (let id = 1; id <= 3; id++) store.create(`Task ${id}`, "Desc");
widget.update();

const lines = renderWidget(ui.state);
expect(lines.slice(1).map(line => line.match(/#(\d+)/)?.[1])).toEqual(["3", "2", "1"]);
});

it("reverses updated-time order", () => {
vi.setSystemTime(100);
store.create("Older", "Desc");
vi.setSystemTime(200);
store.create("Newer", "Desc");
widget = new TaskWidget(store, { sortOrder: "recent", sortDirection: "descending" });
widget.setUICtx(ui.ctx);
widget.update();

const lines = renderWidget(ui.state);
expect(lines[1]).toContain("Older");
expect(lines[2]).toContain("Newer");
});

it("does not mutate store order when rendering descending", () => {
for (let id = 1; id <= 3; id++) store.create(`Task ${id}`, "Desc");
widget = new TaskWidget(store, { sortOrder: "id", sortDirection: "descending" });
widget.setUICtx(ui.ctx);
widget.update();
expect(renderWidget(ui.state).slice(1).map(line => line.match(/#(\d+)/)?.[1])).toEqual(["3", "2", "1"]);

const ascendingWidget = new TaskWidget(store, { sortOrder: "id" });
ascendingWidget.setUICtx(ui.ctx);
ascendingWidget.update();
expect(renderWidget(ui.state).slice(1).map(line => line.match(/#(\d+)/)?.[1])).toEqual(["1", "2", "3"]);
ascendingWidget.dispose();
});

it("defaults to ID order when sortOrder is unset", () => {
store.create("Pending task", "Desc"); // #1
store.create("Completed task", "Desc"); // #2
Expand Down