@mblarsen/pi-task-ui 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/index.ts +221 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,14 @@ The sidebar opens automatically as a non-capturing overlay on the right. Toggle
|
|
|
28
28
|
/task-ui
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
Open the read-only task browser with `Alt+Shift+U` or:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
/task-ui browse
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The browser uses most of the terminal and shows all projected tasks in stable hierarchy and number order, including terminal history. It starts on the focused task and scrolls as you move through the complete list. Use `↑`/`↓` or `j`/`k` to move, `Ctrl-U`/`Ctrl-D` to move by half a viewport, `gg`/`gG` to jump to the first or last task, and `Esc` or `q` to close it. Browse mode does not change the task projection.
|
|
38
|
+
|
|
31
39
|
The bar hides responsively below 72 terminal columns. Its `Tasks` panel shows numbered work, nested subtasks, blockers, terminal history, optional right-aligned labels, and projected execution telemetry without a summary or progress bar. Subtasks use stable hierarchical labels such as `#2.1` and `#2.1.1` and render immediately beneath their parent in subtask order. Active and pending work share one stable list capped at the first seven items, so the earliest work retains priority; overflow is summarized as `… and N more`. `history` shows the latest three terminal transitions newest-first and does not reorder them after metadata or output edits. When only history remains, a muted `All done!` message appears above it.
|
|
32
40
|
|
|
33
41
|
| Icon | Meaning |
|
package/index.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
matchesKey,
|
|
5
|
+
Text,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
type KeybindingsManager,
|
|
9
|
+
type OverlayHandle,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
4
11
|
import { Type } from "typebox";
|
|
5
12
|
import {
|
|
6
13
|
TASK_STATUSES,
|
|
@@ -295,7 +302,7 @@ function taskLine(
|
|
|
295
302
|
return `${indent}${COMPLETED_ICON} ${theme.fg("dim", theme.strikethrough(taskLabel))}`;
|
|
296
303
|
}
|
|
297
304
|
const content = `${indent}${glyph} ${taskLabel}`;
|
|
298
|
-
if (task.status === "failed") return theme.fg("error",
|
|
305
|
+
if (task.status === "failed") return `${indent}${theme.fg("error", "✖")} ${theme.fg("dim", taskLabel)}`;
|
|
299
306
|
if (task.status === "pending") return theme.fg("muted", content);
|
|
300
307
|
if (task.status === "stopped") return theme.fg("dim", content);
|
|
301
308
|
return focused ? theme.bold(content) : content;
|
|
@@ -359,7 +366,7 @@ export class TaskBarComponent {
|
|
|
359
366
|
task.label,
|
|
360
367
|
width,
|
|
361
368
|
this.theme,
|
|
362
|
-
task.status === "completed" || task.status === "stopped",
|
|
369
|
+
task.status === "completed" || task.status === "failed" || task.status === "stopped",
|
|
363
370
|
));
|
|
364
371
|
}
|
|
365
372
|
}
|
|
@@ -372,6 +379,133 @@ export class TaskBarComponent {
|
|
|
372
379
|
invalidate(): void {}
|
|
373
380
|
}
|
|
374
381
|
|
|
382
|
+
export class TaskBrowserComponent {
|
|
383
|
+
private selectedTaskId: string | undefined;
|
|
384
|
+
private scrollOffset = 0;
|
|
385
|
+
private awaitingG = false;
|
|
386
|
+
private readonly getState: () => TaskUiState;
|
|
387
|
+
private readonly getSpinnerFrame: () => string;
|
|
388
|
+
private readonly getViewportHeight: () => number;
|
|
389
|
+
private readonly theme: Theme;
|
|
390
|
+
private readonly keybindings: KeybindingsManager;
|
|
391
|
+
private readonly requestRender: () => void;
|
|
392
|
+
private readonly onClose: () => void;
|
|
393
|
+
|
|
394
|
+
constructor(
|
|
395
|
+
getState: () => TaskUiState,
|
|
396
|
+
getSpinnerFrame: () => string,
|
|
397
|
+
getViewportHeight: () => number,
|
|
398
|
+
theme: Theme,
|
|
399
|
+
keybindings: KeybindingsManager,
|
|
400
|
+
requestRender: () => void,
|
|
401
|
+
onClose: () => void,
|
|
402
|
+
) {
|
|
403
|
+
this.getState = getState;
|
|
404
|
+
this.getSpinnerFrame = getSpinnerFrame;
|
|
405
|
+
this.getViewportHeight = getViewportHeight;
|
|
406
|
+
this.theme = theme;
|
|
407
|
+
this.keybindings = keybindings;
|
|
408
|
+
this.requestRender = requestRender;
|
|
409
|
+
this.onClose = onClose;
|
|
410
|
+
const state = this.getState();
|
|
411
|
+
const ordered = orderTasksForDisplay(state.tasks);
|
|
412
|
+
this.selectedTaskId = ordered.some((task) => task.id === state.focusedTaskId)
|
|
413
|
+
? state.focusedTaskId
|
|
414
|
+
: ordered[0]?.id;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
getSelectedTaskId(): string | undefined {
|
|
418
|
+
return this.selectedTaskId;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
handleInput(data: string): void {
|
|
422
|
+
if (this.keybindings.matches(data, "tui.select.cancel") || data === "q") {
|
|
423
|
+
this.onClose();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const tasks = orderTasksForDisplay(this.getState().tasks);
|
|
428
|
+
if (!tasks.length) return;
|
|
429
|
+
const currentIndex = Math.max(0, tasks.findIndex((task) => task.id === this.selectedTaskId));
|
|
430
|
+
const halfPage = Math.max(1, Math.floor(this.getListCapacity() / 2));
|
|
431
|
+
let nextIndex = currentIndex;
|
|
432
|
+
|
|
433
|
+
if (this.awaitingG) {
|
|
434
|
+
this.awaitingG = false;
|
|
435
|
+
if (data === "g") nextIndex = 0;
|
|
436
|
+
else if (data === "G") nextIndex = tasks.length - 1;
|
|
437
|
+
else return;
|
|
438
|
+
} else if (data === "g") {
|
|
439
|
+
this.awaitingG = true;
|
|
440
|
+
return;
|
|
441
|
+
} else if (this.keybindings.matches(data, "tui.select.up") || data === "k") {
|
|
442
|
+
nextIndex = Math.max(0, currentIndex - 1);
|
|
443
|
+
} else if (this.keybindings.matches(data, "tui.select.down") || data === "j") {
|
|
444
|
+
nextIndex = Math.min(tasks.length - 1, currentIndex + 1);
|
|
445
|
+
} else if (matchesKey(data, "ctrl+u")) {
|
|
446
|
+
nextIndex = Math.max(0, currentIndex - halfPage);
|
|
447
|
+
} else if (matchesKey(data, "ctrl+d")) {
|
|
448
|
+
nextIndex = Math.min(tasks.length - 1, currentIndex + halfPage);
|
|
449
|
+
} else {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.selectedTaskId = tasks[nextIndex]?.id;
|
|
454
|
+
this.requestRender();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
render(width: number): string[] {
|
|
458
|
+
const state = this.getState();
|
|
459
|
+
const tasks = orderTasksForDisplay(state.tasks);
|
|
460
|
+
let selectedIndex = tasks.findIndex((task) => task.id === this.selectedTaskId);
|
|
461
|
+
if (selectedIndex < 0 && tasks.length) {
|
|
462
|
+
selectedIndex = 0;
|
|
463
|
+
this.selectedTaskId = tasks[0]?.id;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const capacity = this.getListCapacity();
|
|
467
|
+
if (selectedIndex < this.scrollOffset) this.scrollOffset = selectedIndex;
|
|
468
|
+
if (selectedIndex >= this.scrollOffset + capacity) this.scrollOffset = selectedIndex - capacity + 1;
|
|
469
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, Math.max(0, tasks.length - capacity)));
|
|
470
|
+
|
|
471
|
+
const position = selectedIndex >= 0 ? `${selectedIndex + 1}/${tasks.length}` : "0/0";
|
|
472
|
+
const title = ` Tasks · ${position} `;
|
|
473
|
+
const topFill = Math.max(0, width - visibleWidth(title) - 2);
|
|
474
|
+
const lines = [this.theme.fg("borderAccent", `╭${title}${"─".repeat(topFill)}╮`)];
|
|
475
|
+
|
|
476
|
+
if (!tasks.length) {
|
|
477
|
+
lines.push(framedRow(this.theme.fg("muted", "No projected tasks"), width, this.theme));
|
|
478
|
+
} else {
|
|
479
|
+
for (const [visibleIndex, task] of tasks.slice(this.scrollOffset, this.scrollOffset + capacity).entries()) {
|
|
480
|
+
const taskIndex = this.scrollOffset + visibleIndex;
|
|
481
|
+
const selected = taskIndex === selectedIndex;
|
|
482
|
+
const prefix = selected ? this.theme.fg("accent", "› ") : " ";
|
|
483
|
+
lines.push(framedTaskRow(
|
|
484
|
+
prefix + taskLine(task, state.tasks, selected, this.getSpinnerFrame(), this.theme),
|
|
485
|
+
task.label,
|
|
486
|
+
width,
|
|
487
|
+
this.theme,
|
|
488
|
+
task.status === "completed" || task.status === "failed" || task.status === "stopped",
|
|
489
|
+
));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
lines.push(framedRow(
|
|
494
|
+
this.theme.fg("dim", "↑↓/jk move · Ctrl-U/D half-page · gg/gG jump · Esc/q close"),
|
|
495
|
+
width,
|
|
496
|
+
this.theme,
|
|
497
|
+
));
|
|
498
|
+
lines.push(this.theme.fg("borderAccent", `╰${"─".repeat(Math.max(0, width - 2))}╯`));
|
|
499
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
invalidate(): void {}
|
|
503
|
+
|
|
504
|
+
private getListCapacity(): number {
|
|
505
|
+
return Math.max(1, this.getViewportHeight() - 3);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
375
509
|
function renderToolCall(name: string, detail: string | undefined, theme: Theme): Text {
|
|
376
510
|
const suffix = detail ? ` ${theme.fg("dim", detail)}` : "";
|
|
377
511
|
return new Text(theme.fg("toolTitle", theme.bold(name)) + suffix, 0, 0);
|
|
@@ -394,6 +528,8 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
394
528
|
let overlayHandle: OverlayHandle | undefined;
|
|
395
529
|
let overlayVisible = true;
|
|
396
530
|
let requestRender: (() => void) | undefined;
|
|
531
|
+
let browseRequestRender: (() => void) | undefined;
|
|
532
|
+
let browseOpen = false;
|
|
397
533
|
let sessionActive = false;
|
|
398
534
|
let spinnerFrame = 0;
|
|
399
535
|
let animationTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -405,8 +541,13 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
405
541
|
spinnerFrame = 0;
|
|
406
542
|
};
|
|
407
543
|
|
|
544
|
+
const requestAllRenders = () => {
|
|
545
|
+
requestRender?.();
|
|
546
|
+
browseRequestRender?.();
|
|
547
|
+
};
|
|
548
|
+
|
|
408
549
|
const syncAnimation = () => {
|
|
409
|
-
const shouldAnimate = sessionActive && overlayVisible && state.tasks.some((task) => task.executing);
|
|
550
|
+
const shouldAnimate = sessionActive && (overlayVisible || browseOpen) && state.tasks.some((task) => task.executing);
|
|
410
551
|
if (!shouldAnimate) {
|
|
411
552
|
stopAnimation();
|
|
412
553
|
return;
|
|
@@ -414,13 +555,13 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
414
555
|
if (animationTimer) return;
|
|
415
556
|
animationTimer = setInterval(() => {
|
|
416
557
|
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
417
|
-
|
|
558
|
+
requestAllRenders();
|
|
418
559
|
}, 500);
|
|
419
560
|
};
|
|
420
561
|
|
|
421
562
|
const publishState = () => {
|
|
422
563
|
if (sessionActive) pi.appendEntry(STATE_ENTRY_TYPE, cloneTaskUiState(state));
|
|
423
|
-
|
|
564
|
+
requestAllRenders();
|
|
424
565
|
syncAnimation();
|
|
425
566
|
};
|
|
426
567
|
|
|
@@ -467,6 +608,50 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
467
608
|
});
|
|
468
609
|
};
|
|
469
610
|
|
|
611
|
+
const showBrowser = async (ctx: ExtensionContext) => {
|
|
612
|
+
if (ctx.mode !== "tui") {
|
|
613
|
+
ctx.ui.notify("Task browser requires interactive mode", "warning");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!state.tasks.length) {
|
|
617
|
+
ctx.ui.notify("No projected tasks to browse", "info");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const restoreSidebar = overlayVisible && overlayHandle !== undefined && !overlayHandle.isHidden();
|
|
622
|
+
if (restoreSidebar) overlayHandle?.setHidden(true);
|
|
623
|
+
browseOpen = true;
|
|
624
|
+
syncAnimation();
|
|
625
|
+
try {
|
|
626
|
+
await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
|
|
627
|
+
browseRequestRender = () => tui.requestRender();
|
|
628
|
+
return new TaskBrowserComponent(
|
|
629
|
+
() => state,
|
|
630
|
+
() => SPINNER_FRAMES[spinnerFrame],
|
|
631
|
+
() => Math.max(4, Math.floor(tui.terminal.rows * 0.9)),
|
|
632
|
+
theme,
|
|
633
|
+
keybindings,
|
|
634
|
+
() => tui.requestRender(),
|
|
635
|
+
() => done(),
|
|
636
|
+
);
|
|
637
|
+
}, {
|
|
638
|
+
overlay: true,
|
|
639
|
+
overlayOptions: {
|
|
640
|
+
anchor: "center",
|
|
641
|
+
width: "92%",
|
|
642
|
+
maxHeight: "90%",
|
|
643
|
+
margin: 1,
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
} finally {
|
|
647
|
+
browseOpen = false;
|
|
648
|
+
browseRequestRender = undefined;
|
|
649
|
+
if (restoreSidebar && overlayVisible) overlayHandle?.setHidden(false);
|
|
650
|
+
requestRender?.();
|
|
651
|
+
syncAnimation();
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
470
655
|
const toggleOverlay = (ctx: ExtensionContext) => {
|
|
471
656
|
if (ctx.mode !== "tui") {
|
|
472
657
|
ctx.ui.notify("Task UI requires interactive mode", "warning");
|
|
@@ -710,9 +895,32 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
710
895
|
handler: async (ctx) => toggleOverlay(ctx),
|
|
711
896
|
});
|
|
712
897
|
|
|
898
|
+
pi.registerShortcut("alt+shift+u", {
|
|
899
|
+
description: "Open the read-only task browser",
|
|
900
|
+
handler: async (ctx) => showBrowser(ctx),
|
|
901
|
+
});
|
|
902
|
+
|
|
713
903
|
pi.registerCommand("task-ui", {
|
|
714
|
-
description: "Toggle the
|
|
715
|
-
|
|
904
|
+
description: "Toggle the task sidebar, or use /task-ui browse for the full task browser",
|
|
905
|
+
getArgumentCompletions: (prefix) => {
|
|
906
|
+
const items = [
|
|
907
|
+
{ value: "browse", label: "browse", description: "Open the full task browser" },
|
|
908
|
+
{ value: "toggle", label: "toggle", description: "Toggle the task sidebar" },
|
|
909
|
+
].filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
910
|
+
return items.length ? items : null;
|
|
911
|
+
},
|
|
912
|
+
handler: async (args, ctx) => {
|
|
913
|
+
const action = args.trim().toLowerCase();
|
|
914
|
+
if (!action || action === "toggle") {
|
|
915
|
+
toggleOverlay(ctx);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (action === "browse") {
|
|
919
|
+
await showBrowser(ctx);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
ctx.ui.notify("Usage: /task-ui [toggle|browse]", "warning");
|
|
923
|
+
},
|
|
716
924
|
});
|
|
717
925
|
|
|
718
926
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -753,6 +961,8 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
753
961
|
}
|
|
754
962
|
overlayHandle = undefined;
|
|
755
963
|
requestRender = undefined;
|
|
964
|
+
browseRequestRender = undefined;
|
|
965
|
+
browseOpen = false;
|
|
756
966
|
overlayVisible = true;
|
|
757
967
|
showOverlay(ctx);
|
|
758
968
|
syncAnimation();
|
|
@@ -766,7 +976,7 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
766
976
|
const restored = normalizeStoredTaskUiState(entry.data);
|
|
767
977
|
if (restored) state = restored;
|
|
768
978
|
}
|
|
769
|
-
|
|
979
|
+
requestAllRenders();
|
|
770
980
|
syncAnimation();
|
|
771
981
|
});
|
|
772
982
|
|
|
@@ -778,5 +988,7 @@ export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
|
778
988
|
overlayHandle?.hide();
|
|
779
989
|
overlayHandle = undefined;
|
|
780
990
|
requestRender = undefined;
|
|
991
|
+
browseRequestRender = undefined;
|
|
992
|
+
browseOpen = false;
|
|
781
993
|
});
|
|
782
994
|
}
|