@pi9/todo 0.1.0 → 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/src/widget.ts CHANGED
@@ -1,57 +1,102 @@
1
1
  import type { Component, TUI } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
 
4
- import type { TodoState } from "./types.js";
4
+ import type { TodoSettings } from "./settings.js";
5
+ import { isTerminalTodo, type TodoState } from "./types.js";
5
6
  import { TodoWidgetComponent } from "./widget-component.js";
6
7
 
7
- export type TodoWidgetPlacement = "belowEditor" | "aboveEditor" | "off";
8
+ export type TodoWidgetSettings = Partial<Pick<
9
+ TodoSettings,
10
+ "widgetPlacement" | "maxVisibleTasks" | "fallbackGlyphs"
11
+ >>;
8
12
 
9
- /**
10
- * Deliberately structural so this unit does not depend on the settings persistence layer.
11
- * The widget-prefixed forms allow a future settings module to expose namespaced UI settings.
12
- */
13
- export type TodoWidgetSettings = {
14
- widgetPlacement?: TodoWidgetPlacement;
15
- maxVisibleTasks?: number;
16
- fallbackGlyphs?: boolean;
13
+ type WidgetComponentFactory = (tui: TUI, theme: Theme) => Component & { dispose?(): void };
14
+ type TodoWidgetUI = {
15
+ notify?: (message: string, level?: "info" | "warning" | "error") => void;
16
+ setWidget?: {
17
+ (id: string, content: string[] | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
18
+ (id: string, content: WidgetComponentFactory | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
19
+ };
17
20
  };
18
21
 
19
- type WidgetComponentFactory = (tui: TUI, theme: Theme) => Component & { dispose?(): void };
22
+ const TERMINAL_WIDGET_DELAY_MS = 5_000;
23
+ type TodoWidgetLifecycle = {
24
+ hadOpenTasks: boolean;
25
+ terminalClearTimer?: ReturnType<typeof setTimeout>;
26
+ };
27
+ const widgetLifecycles = new WeakMap<TodoWidgetUI, TodoWidgetLifecycle>();
20
28
 
21
29
  export type TodoWidgetContext = {
22
30
  hasUI?: boolean;
23
- ui?: {
24
- notify?: (message: string, level?: "info" | "warning" | "error") => void;
25
- setWidget?: {
26
- (id: string, content: string[] | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
27
- (id: string, content: WidgetComponentFactory | undefined, options?: { placement?: "belowEditor" | "aboveEditor" }): void;
28
- };
29
- };
31
+ ui?: TodoWidgetUI;
30
32
  };
31
33
 
32
34
  /** Update (or clear) the persistent todo widget without requiring the host UI at runtime. */
33
35
  export function updateTodoWidget(ctx: TodoWidgetContext | undefined, state: TodoState | undefined, settings: TodoWidgetSettings = {}): void {
34
36
  if (!ctx?.hasUI || !ctx.ui?.setWidget) return;
37
+ const ui = ctx.ui;
38
+ const lifecycle = widgetLifecycles.get(ui) ?? { hadOpenTasks: false };
39
+
35
40
  try {
36
41
  const placement = settings.widgetPlacement ?? "aboveEditor";
37
42
  if (placement === "off") {
38
- ctx.ui.setWidget("todo", undefined);
43
+ cancelTerminalClear(lifecycle);
44
+ widgetLifecycles.delete(ui);
45
+ ui.setWidget!("todo", undefined);
39
46
  return;
40
47
  }
41
48
 
42
- const hasVisibleTasks = state?.phases.some(phase => phase.tasks.some(task =>
43
- task.status === "pending" || task.status === "in_progress",
44
- )) ?? false;
45
- const factory: WidgetComponentFactory | undefined = hasVisibleTasks
46
- ? (tui, theme) => new TodoWidgetComponent(state!, theme, {
49
+ const hasTasks = state?.phases.some((phase) => phase.tasks.length > 0) ?? false;
50
+ const hasOpenTasks = state?.phases.some((phase) => phase.tasks.some((task) => !isTerminalTodo(task))) ?? false;
51
+ const showFinalState = hasTasks && !hasOpenTasks
52
+ && (lifecycle.hadOpenTasks || lifecycle.terminalClearTimer !== undefined);
53
+ const visibleState = hasOpenTasks || showFinalState ? state : undefined;
54
+ const factory: WidgetComponentFactory | undefined = visibleState
55
+ ? (tui, theme) => new TodoWidgetComponent(visibleState, theme, {
47
56
  maxVisible: settings.maxVisibleTasks,
48
57
  fallbackGlyphs: settings.fallbackGlyphs,
58
+ blankLineBelow: placement === "aboveEditor",
49
59
  }, tui)
50
60
  : undefined;
51
- ctx.ui.setWidget("todo", factory, { placement });
61
+ ui.setWidget!("todo", factory, { placement });
62
+
63
+ if (hasOpenTasks) {
64
+ cancelTerminalClear(lifecycle);
65
+ lifecycle.hadOpenTasks = true;
66
+ widgetLifecycles.set(ui, lifecycle);
67
+ } else if (showFinalState) {
68
+ lifecycle.hadOpenTasks = false;
69
+ if (!lifecycle.terminalClearTimer) {
70
+ const timer = setTimeout(() => {
71
+ widgetLifecycles.delete(ui);
72
+ clearWidget(ui);
73
+ }, TERMINAL_WIDGET_DELAY_MS);
74
+ timer.unref?.();
75
+ lifecycle.terminalClearTimer = timer;
76
+ }
77
+ widgetLifecycles.set(ui, lifecycle);
78
+ } else {
79
+ cancelTerminalClear(lifecycle);
80
+ widgetLifecycles.delete(ui);
81
+ }
52
82
  } catch (error) {
53
- try {
54
- ctx.ui.notify?.(`Todo widget update failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
55
- } catch { }
83
+ notifyWidgetError(ui, error);
56
84
  }
57
85
  }
86
+
87
+ function cancelTerminalClear(lifecycle: TodoWidgetLifecycle): void {
88
+ if (lifecycle.terminalClearTimer) clearTimeout(lifecycle.terminalClearTimer);
89
+ delete lifecycle.terminalClearTimer;
90
+ }
91
+
92
+ function clearWidget(ui: TodoWidgetUI): void {
93
+ try {
94
+ ui.setWidget?.("todo", undefined);
95
+ } catch (error) {
96
+ notifyWidgetError(ui, error);
97
+ }
98
+ }
99
+
100
+ function notifyWidgetError(ui: TodoWidgetUI, error: unknown): void {
101
+ ui.notify?.(`Todo widget update failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
102
+ }