From 1c431b31d7a46021ac156f3a0430b416bd0c1973 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Sun, 26 Jul 2026 23:39:45 +0800 Subject: [PATCH 1/3] feat(widget): focus overflowing task lists --- README.md | 14 ++- src/ui/settings-menu.ts | 12 +-- src/ui/task-widget.ts | 53 +++++----- test/task-widget-ui-context.test.ts | 145 ++++++++++++++++++++++++++++ test/task-widget.test.ts | 103 ++++++++++---------- 5 files changed, 236 insertions(+), 91 deletions(-) create mode 100644 test/task-widget-ui-context.test.ts diff --git a/README.md b/README.md index b89bc72..f2a2985 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 starts at the first unfinished task in the configured sort order and displays up to `maxVisible` tasks. 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..64a3e20 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 starting at the first unfinished task.", 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..0cf0489 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,19 @@ 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 firstUnfinished = tasks.findIndex(task => task.status !== "completed"); + const windowStart = firstUnfinished === -1 + ? Math.max(0, tasks.length - limit) + : firstUnfinished; + 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 +230,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..2025a5f 100644 --- a/test/task-widget.test.ts +++ b/test/task-widget.test.ts @@ -173,44 +173,51 @@ 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("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 +229,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 From 46f2a2eb72c875e40756806cd048a1e3795cbe92 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Sun, 26 Jul 2026 16:15:00 +0800 Subject: [PATCH 2/3] fix(widget): keep focused task window full --- src/ui/task-widget.ts | 9 +++++---- test/task-widget.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/ui/task-widget.ts b/src/ui/task-widget.ts index 0cf0489..6a9a09f 100644 --- a/src/ui/task-widget.ts +++ b/src/ui/task-widget.ts @@ -163,10 +163,11 @@ export class TaskWidget { const showAll = toolsExpanded || (this.config.showAll ?? false); const limit = this.config.maxVisible ?? DEFAULT_MAX_VISIBLE_TASKS; - const firstUnfinished = tasks.findIndex(task => task.status !== "completed"); - const windowStart = firstUnfinished === -1 - ? Math.max(0, tasks.length - limit) - : firstUnfinished; + 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; diff --git a/test/task-widget.test.ts b/test/task-widget.test.ts index 2025a5f..32c2388 100644 --- a/test/task-widget.test.ts +++ b/test/task-widget.test.ts @@ -203,6 +203,22 @@ describe("TaskWidget", () => { expect(lines[7]).toContain("3 later"); }); + 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); From 6c45ca9d20e030d54812d62861cb7248fc91163b Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Sun, 26 Jul 2026 18:18:45 +0800 Subject: [PATCH 3/3] docs(widget): describe focused window backfill --- README.md | 2 +- src/ui/settings-menu.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f2a2985..7210090 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ How tasks are sorted and how many are shown can be configured via `/tasks` → S | `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 | -When a collapsed list exceeds `maxVisible`, the widget starts at the first unfinished task in the configured sort order and displays up to `maxVisible` tasks. 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. +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. diff --git a/src/ui/settings-menu.ts b/src/ui/settings-menu.ts index 64a3e20..350c6b1 100644 --- a/src/ui/settings-menu.ts +++ b/src/ui/settings-menu.ts @@ -63,7 +63,7 @@ export async function openSettingsMenu( label: "Max visible tasks in widget", description: "Only applies when 'Show all tasks' is OFF and Pi's global tool view is collapsed. " + - "Sets the focused window size starting at the first unfinished task.", + "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"], },