@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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/index.ts +221 -9
  3. 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 { Text, truncateToWidth, visibleWidth, type OverlayHandle } from "@earendil-works/pi-tui";
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", content);
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
- requestRender?.();
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
- requestRender?.();
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 non-capturing task sidebar",
715
- handler: async (_args, ctx) => toggleOverlay(ctx),
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
- requestRender?.();
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblarsen/pi-task-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Backend-neutral task sidebar and agent tools for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",