Skip to content

Commit d23293b

Browse files
committed
feat(widget): focus overflowing task lists
1 parent 3b3d0f1 commit d23293b

5 files changed

Lines changed: 165 additions & 111 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,20 @@ The extension renders a persistent widget above the editor:
5555

5656
### Widget display settings
5757

58-
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.
58+
How tasks are sorted and how many are shown can be configured via `/tasks` → Settings (saved as project overrides in `.pi/tasks-config.json`).
5959

6060
| Setting | Values | Default | Behaviour |
6161
|---------|--------|---------|-----------|
6262
| `sortOrder` | `id` / `status` / `recent` / `oldest` | `id` | `id` = creation order; `status` groups completed → in-progress → pending; `recent`/`oldest` = by last-updated time |
63-
| `maxVisible` | `5`–`100` | `10` | Caps how many task lines the widget shows (ignored when `showAll` is on) |
64-
| `showAll` | `true` / `false` | `false` | When `true`, every task is shown regardless of `maxVisible` |
65-
| `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 |
63+
| `maxVisible` | `5`–`100` | `10` | Window size when the global tool view is collapsed and `showAll` is off |
64+
| `showAll` | `true` / `false` | `false` | When `true`, every task is always shown regardless of `maxVisible` |
65+
| `hiddenAt` | `bottom` / `top` | `bottom` | Legacy compatibility setting retained in config; the focused task window now uses separate earlier/later markers |
6666

67-
> 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.
67+
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.
68+
69+
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.
70+
71+
> Note: the widget's `status` order is completed-first, which is the reverse of the `TaskList` tool's pending-first order.
6872
6973
## Tools
7074

‎src/ui/settings-menu.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,17 @@ export async function openSettingsMenu(
5353
id: "showAll",
5454
label: "Show all tasks in widget",
5555
description:
56-
"When ON, every task is shown regardless of the visible limit. " +
57-
"When OFF, the list is capped by 'Max visible tasks'.",
56+
"When ON, every task is always shown. " +
57+
"When OFF, the widget uses a focused window unless Pi's global tool view is expanded.",
5858
currentValue: (cfg.showAll ?? false) ? "on" : "off",
5959
values: ["on", "off"],
6060
},
6161
{
6262
id: "maxVisible",
6363
label: "Max visible tasks in widget",
6464
description:
65-
"Only applies when 'Show all tasks' is OFF. " +
66-
"Caps how many task lines the widget shows.",
65+
"Only applies when 'Show all tasks' is OFF and Pi's global tool view is collapsed. " +
66+
"Sets the focused window size starting at the first unfinished task.",
6767
currentValue: String(cfg.maxVisible ?? 10),
6868
values: ["5", "10", "15", "20", "30", "50", "100"],
6969
},
@@ -80,8 +80,8 @@ export async function openSettingsMenu(
8080
id: "hiddenAt",
8181
label: "Hidden tasks position",
8282
description:
83-
'"bottom" hides tasks from the end of the list. ' +
84-
'"top" hides tasks from the start (useful with status sort to collapse completed tasks).',
83+
"Legacy compatibility setting. The focused window now shows separate earlier/later markers " +
84+
"and follows Pi's global tool expansion state.",
8585
currentValue: cfg.hiddenAt ?? "bottom",
8686
values: ["bottom", "top"],
8787
},

‎src/ui/task-widget.ts‎

Lines changed: 49 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,6 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
1212
import type { TaskStore } from "../task-store.js";
1313
import type { TasksConfig } from "../tasks-config.js";
1414

15-
// ---- Truncation ----
16-
17-
import type { Task } from "../types.js";
18-
19-
function truncateFromTop(tasks: Task[], limit: number): Task[] {
20-
return tasks.slice(-limit);
21-
}
22-
23-
function truncateFromBottom(tasks: Task[], limit: number): Task[] {
24-
return tasks.slice(0, limit);
25-
}
26-
27-
const TRUNCATE_FNS = { top: truncateFromTop, bottom: truncateFromBottom };
28-
2915
// ---- Types ----
3016

3117
export type Theme = {
@@ -34,13 +20,20 @@ export type Theme = {
3420
strikethrough(text: string): string;
3521
};
3622

23+
export type WidgetComponent = {
24+
render(): string[];
25+
invalidate(): void;
26+
setExpanded?(expanded: boolean): void;
27+
};
28+
3729
export type UICtx = {
3830
setStatus(key: string, text: string | undefined): void;
3931
setWidget(
4032
key: string,
41-
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
33+
content: undefined | ((tui: any, theme: Theme) => WidgetComponent),
4234
options?: { placement?: "aboveEditor" | "belowEditor" },
4335
): void;
36+
getToolsExpanded?(): boolean;
4437
};
4538

4639
/** Firework frames for the animated active task indicator (matches Claude Code). */
@@ -111,6 +104,10 @@ export class TaskWidget {
111104
}
112105

113106
setUICtx(ctx: UICtx) {
107+
if (this.uiCtx !== ctx) {
108+
this.widgetRegistered = false;
109+
this.tui = undefined;
110+
}
114111
this.uiCtx = ctx;
115112
}
116113

@@ -154,19 +151,8 @@ export class TaskWidget {
154151
}
155152
}
156153

157-
/** Render callback entry point. Guarded so a render error can never escape to
158-
* the TUI timer and crash the whole host process — worst case the widget is
159-
* empty for one frame. */
160-
private renderWidget(tui: any, theme: Theme): string[] {
161-
try {
162-
return this.buildWidgetLines(tui, theme);
163-
} catch {
164-
return [];
165-
}
166-
}
167-
168154
/** Build widget lines from current live state. */
169-
private buildWidgetLines(tui: any, theme: Theme): string[] {
155+
private buildWidgetLines(tui: any, theme: Theme, toolsExpanded = false): string[] {
170156
const sortOrder = this.config.sortOrder ?? "id";
171157
const tasks = this.store.list(sortOrder);
172158
const w = tui.terminal.columns;
@@ -187,18 +173,19 @@ export class TaskWidget {
187173
const spinnerChar = this.fireworkFrames[this.fireworkFrame];
188174
const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))];
189175

190-
const showAll = this.config.showAll ?? false;
176+
const showAll = toolsExpanded || (this.config.showAll ?? false);
191177
const limit = this.config.maxVisible ?? DEFAULT_MAX_VISIBLE_TASKS;
192-
const hiddenAt = this.config.hiddenAt ?? "bottom";
193-
const visible = showAll ? tasks : TRUNCATE_FNS[hiddenAt](tasks, limit);
194-
195-
const hiddenCount = tasks.length - visible.length;
196-
const overflowLine = hiddenCount > 0
197-
? truncate(theme.fg("dim", ` … and ${hiddenCount} more`))
198-
: undefined;
199-
200-
if (overflowLine && hiddenAt === "top") {
201-
lines.push(overflowLine);
178+
const firstUnfinished = tasks.findIndex(task => task.status !== "completed");
179+
const windowStart = firstUnfinished === -1
180+
? Math.max(0, tasks.length - limit)
181+
: firstUnfinished;
182+
const shouldWindow = !showAll && tasks.length > limit;
183+
const visible = shouldWindow ? tasks.slice(windowStart, windowStart + limit) : tasks;
184+
const hiddenBefore = shouldWindow ? windowStart : 0;
185+
const hiddenAfter = shouldWindow ? tasks.length - windowStart - visible.length : 0;
186+
187+
if (hiddenBefore > 0) {
188+
lines.push(truncate(theme.fg("dim", ` … ${hiddenBefore} earlier`)));
202189
}
203190
for (let i = 0; i < visible.length; i++) {
204191
const task = visible[i];
@@ -216,8 +203,8 @@ export class TaskWidget {
216203
}
217204

218205
let suffix = "";
219-
if (task.status === "pending" && task.blockedBy.length > 0) {
220-
const openBlockers = task.blockedBy.filter(bid => {
206+
if (task.status === "pending" && (task.blockedBy?.length ?? 0) > 0) {
207+
const openBlockers = (task.blockedBy ?? []).filter(bid => {
221208
const blocker = this.store.get(bid);
222209
return blocker && blocker.status !== "completed";
223210
});
@@ -255,8 +242,8 @@ export class TaskWidget {
255242
lines.push(truncate(text + suffix));
256243
}
257244

258-
if (overflowLine && hiddenAt !== "top") {
259-
lines.push(overflowLine);
245+
if (hiddenAfter > 0) {
246+
lines.push(truncate(theme.fg("dim", ` … ${hiddenAfter} later`)));
260247
}
261248

262249
return lines;
@@ -298,11 +285,29 @@ export class TaskWidget {
298285
this.widgetInterval = undefined;
299286
}
300287

301-
// Transition: hidden → visible — register widget callback once
288+
// Transition: hidden → visible — register one expandable widget component.
302289
if (!this.widgetRegistered) {
290+
const initiallyExpanded = this.uiCtx.getToolsExpanded?.() ?? false;
303291
this.uiCtx.setWidget("tasks", (tui, theme) => {
304292
this.tui = tui;
305-
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} };
293+
let toolsExpanded = initiallyExpanded;
294+
return {
295+
render: () => {
296+
try {
297+
// Read Pi's public global state at render time. setExpanded()
298+
// remains as a fallback for hosts that propagate it to widgets.
299+
const expanded = this.uiCtx?.getToolsExpanded?.() ?? toolsExpanded;
300+
return this.buildWidgetLines(tui, theme, expanded);
301+
} catch {
302+
// A malformed legacy task must never escape into Pi's TUI render loop.
303+
return [];
304+
}
305+
},
306+
invalidate: () => {},
307+
setExpanded(expanded: boolean) {
308+
toolsExpanded = expanded;
309+
},
310+
};
306311
}, { placement: "aboveEditor" });
307312
this.widgetRegistered = true;
308313
} else if (this.tui) {

‎test/subagent-integration.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,7 @@ describe("Widget agent ID display", () => {
947947
const ctx: UICtx = {
948948
setWidget(key, content, options) { state.widgets.set(key, { content, options }); },
949949
setStatus(key, text) { state.statuses.set(key, text); },
950+
getToolsExpanded() { return false; },
950951
};
951952
return { ctx, state };
952953
}

0 commit comments

Comments
 (0)