pi-squire 0.20.1 → 0.20.2

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.
@@ -1,7 +1,7 @@
1
1
  import { stripVTControlCharacters } from "node:util";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
- import type { AncestorRow } from "./widget-ancestors.ts";
4
+ import type { Breadcrumb, WidgetIdentity } from "./widget-ancestors.ts";
5
5
  import type { DelegateRow } from "./widget-data.ts";
6
6
 
7
7
  export type WidgetTheme = Pick<Theme, "fg" | "bold">;
@@ -18,6 +18,15 @@ export function widgetText(value: string): string {
18
18
  .slice(0, 2048);
19
19
  }
20
20
 
21
+ export function widgetName(row: WidgetIdentity): string {
22
+ const prefix = row.agent ? `${row.agent}-` : "";
23
+ return widgetText(
24
+ prefix && row.identity.startsWith(prefix) && row.identity.length > prefix.length
25
+ ? row.identity.slice(prefix.length)
26
+ : row.identity,
27
+ );
28
+ }
29
+
21
30
  function age(timestamp: number, now: number): string {
22
31
  const minutes: number = Math.floor(Math.max(0, now - timestamp) / 60_000);
23
32
  if (minutes < 1) return "just now";
@@ -34,9 +43,9 @@ function appearance(row: DelegateRow): { icon: string; color: "accent" | "succes
34
43
 
35
44
  function metadata(row: DelegateRow, width: number, coreWidth: number, now: number): string {
36
45
  const model: string = widgetText(row.model.split("/").slice(1).join("/") || row.model);
37
- const seen: string = row.lastActive === undefined ? "" : `file ${age(row.lastActive, now)}`;
46
+ const seen: string = row.lastActive === undefined ? "" : `active ${age(row.lastActive, now)}`;
38
47
  const columns: string[] = [seen, model].filter(Boolean);
39
- // Remove model, then file-age, before sacrificing identity/state/title.
48
+ // Remove model, then active-age, before sacrificing identity/state/title.
40
49
  while (columns.length && coreWidth + visibleWidth(` · ${columns.join(" · ")}`) > width) columns.pop();
41
50
  return columns.length ? ` · ${columns.join(" · ")}` : "";
42
51
  }
@@ -44,48 +53,42 @@ function metadata(row: DelegateRow, width: number, coreWidth: number, now: numbe
44
53
  function entry(row: DelegateRow, width: number, connector: string, theme: WidgetTheme, now: number): string {
45
54
  const { icon, color } = appearance(row);
46
55
  const state: string = widgetText(row.state);
47
- const name: string = widgetText(row.identity);
56
+ const name: string = widgetName(row);
48
57
  if (width < 16) return truncateToWidth(`${theme.fg(color, icon)} ${theme.bold(name)}`, width);
49
58
  const prefix: string = `${theme.fg("dim", connector)} ${theme.fg(color, icon)} `;
50
59
  const suffix: string = ` · ${theme.fg(color, state)}`;
51
60
  const title: string = widgetText(row.title ?? "");
52
61
  const preferred: number = visibleWidth(prefix + name + suffix) + (title ? Math.min(24, visibleWidth(title)) + 2 : 0);
53
62
  const extra: string = metadata(row, width, preferred, now);
54
- const room: number = Math.max(1, width - visibleWidth(prefix + suffix + extra));
63
+ const count = !row.expanded && row.descendantCount ? theme.fg("dim", ` +${row.descendantCount}`) : "";
64
+ const room: number = Math.max(1, width - visibleWidth(prefix + suffix + extra + count));
55
65
  const identity: string = truncateToWidth(name, room);
56
66
  const titleRoom: number = room - visibleWidth(identity) - 2;
57
67
  const description: string = title && title !== name && titleRoom >= 4 ? ` ${truncateToWidth(title, titleRoom)}` : "";
58
- return truncateToWidth(`${prefix}${theme.bold(identity)}${description}${suffix}${theme.fg("dim", extra)}`, width);
68
+ return truncateToWidth(
69
+ `${prefix}${theme.bold(identity)}${description}${suffix}${theme.fg("dim", extra)}${count}`,
70
+ width,
71
+ );
59
72
  }
60
73
 
61
- /** One secondary line; descendant task counts take priority when both do not fit. */
62
- export function activityLine(
63
- row: DelegateRow,
64
- width: number,
65
- last: boolean,
66
- theme: WidgetTheme,
67
- summary = "",
68
- ): string | undefined {
69
- const activity: string =
70
- row.running && row.activity
71
- ? `${row.activity.kind === "response" ? "response: " : ""}${widgetText(row.activity.text)}`
72
- : "";
73
- const facts: string = widgetText(summary);
74
- if (!activity && !facts) return;
74
+ /** One secondary line for each running task. */
75
+ export function activityLine(row: DelegateRow, width: number, last: boolean, theme: WidgetTheme): string | undefined {
76
+ if (!row.running) return;
75
77
  const prefix: string = last ? " └─ " : "│ └─ ";
76
- let text: string = [activity, facts].filter(Boolean).join(" · ");
77
- if (facts && visibleWidth(prefix + text) > width) text = facts;
78
- return truncateToWidth(theme.fg("dim", prefix + text), width);
78
+ return truncateToWidth(theme.fg("dim", `${prefix}working...`), width);
79
79
  }
80
80
 
81
81
  export type WidgetRenderOptions = {
82
82
  offset?: number;
83
83
  selectedKey?: string;
84
84
  focused?: boolean;
85
- ancestors?: readonly AncestorRow[];
85
+ breadcrumb?: Breadcrumb;
86
86
  ancestryError?: string;
87
87
  };
88
88
 
89
+ // Unfocused rows keep the selection column so focusing the list never shifts the tree.
90
+ const GUTTER = " ";
91
+
89
92
  function selectableEntry(
90
93
  row: DelegateRow,
91
94
  width: number,
@@ -94,29 +97,34 @@ function selectableEntry(
94
97
  now: number,
95
98
  options: WidgetRenderOptions,
96
99
  ): string {
97
- const gutter: string = options.focused ? " " : "";
98
- const marker: string = options.focused && row.key === options.selectedKey ? theme.fg("accent", "▸ ") : gutter;
100
+ const marker: string = options.focused && row.key === options.selectedKey ? theme.fg("accent", "▸ ") : GUTTER;
99
101
  return marker + entry(row, width - visibleWidth(marker), last ? "└─" : "├─", theme, now);
100
102
  }
101
103
 
102
- function ancestorLines(ancestors: readonly AncestorRow[], options: WidgetRenderOptions, theme: WidgetTheme): string[] {
103
- return ancestors.map((ancestor, index) => {
104
- const gutter: string = options.focused ? " " : "";
105
- const marker: string = options.focused && ancestor.key === options.selectedKey ? theme.fg("accent", "▸ ") : gutter;
106
- const connector: string = index ? `${" ".repeat(index - 1)}└─ ` : "";
107
- return (
108
- marker +
109
- connector +
110
- "↑ " +
111
- theme.bold(widgetText(ancestor.identity)) +
112
- (ancestor.parent ? " (parent)" : "") +
113
- (ancestor.title ? ` ${widgetText(ancestor.title)}` : "")
114
- );
115
- });
104
+ function breadcrumbLine(
105
+ breadcrumb: Breadcrumb,
106
+ width: number,
107
+ options: WidgetRenderOptions,
108
+ theme: WidgetTheme,
109
+ ): string {
110
+ const marker = options.focused && breadcrumb.key === options.selectedKey ? theme.fg("accent", "▸ ") : GUTTER;
111
+ const names = breadcrumb.crumbs.map(widgetName);
112
+ const self = `${widgetName(breadcrumb.self)} (you)`;
113
+ let trail = [...names, self].join(" › ");
114
+ const room = Math.max(0, width - visibleWidth(marker));
115
+ if (visibleWidth(trail) > room && names.length > 2)
116
+ trail = [names[0], "…", names[names.length - 1], self].join(" › ");
117
+ const title = widgetText(breadcrumb.parentTitle ?? "");
118
+ const titleRoom = room - visibleWidth(trail) - 2;
119
+ return (
120
+ marker +
121
+ theme.bold(truncateToWidth(trail, room)) +
122
+ (title && titleRoom >= 4 ? ` ${truncateToWidth(title, titleRoom)}` : "")
123
+ );
116
124
  }
117
125
 
118
126
  function more(count: number): string {
119
- return `${count} more direct delegate${count === 1 ? "" : "s"}`;
127
+ return `${count} more delegate${count === 1 ? "" : "s"}`;
120
128
  }
121
129
 
122
130
  function childLines(
@@ -127,31 +135,32 @@ function childLines(
127
135
  now: number,
128
136
  options: WidgetRenderOptions,
129
137
  ): string[] {
130
- const depth: number = options.ancestors?.length ?? 0;
131
- const indent: string = " ".repeat(depth);
132
- const gutter: string = options.focused ? " " : "";
133
- const branch: string = depth ? `${" ".repeat(depth - 1)}└─ ` : "";
134
- const lines: string[] = [gutter + branch + theme.fg("accent", theme.bold("Delegates"))];
138
+ const lines: string[] = options.breadcrumb ? [] : [GUTTER + theme.fg("accent", theme.bold("Delegates"))];
135
139
  const start: number = Math.max(0, Math.min(options.offset ?? 0, rows.length - cap));
136
140
  const visible: readonly DelegateRow[] = rows.slice(start, start + cap);
137
141
  const hidden: number = rows.length - start - visible.length;
138
- if (start) lines.push(gutter + indent + theme.fg("dim", `├─ ↑ ${more(start)}`));
142
+ if (start) lines.push(GUTTER + theme.fg("dim", `├─ ↑ ${more(start)}`));
139
143
  for (const [index, row] of visible.entries()) {
140
144
  const last: boolean = index === visible.length - 1 && hidden === 0;
141
- lines.push(indent + selectableEntry(row, width - indent.length, last, theme, now, options));
142
- const secondary: string | undefined = activityLine(
143
- row,
144
- width - gutter.length - indent.length,
145
- last,
146
- theme,
147
- row.summary,
148
- );
149
- if (secondary) lines.push(gutter + indent + secondary);
145
+ const rowIndent = " ".repeat(row.depth ?? 0);
146
+ lines.push(rowIndent + selectableEntry(row, width - rowIndent.length, last, theme, now, options));
147
+ const secondary: string | undefined = activityLine(row, width - GUTTER.length - rowIndent.length, last, theme);
148
+ if (secondary) lines.push(GUTTER + rowIndent + secondary);
150
149
  }
151
- if (hidden) lines.push(gutter + indent + theme.fg("dim", `└─ +${more(hidden)}`));
150
+ if (hidden) lines.push(GUTTER + theme.fg("dim", `└─ +${more(hidden)}`));
152
151
  return lines;
153
152
  }
154
153
 
154
+ function focusedHint(rows: readonly DelegateRow[], options: WidgetRenderOptions): string {
155
+ if (options.breadcrumb && options.breadcrumb.key === options.selectedKey)
156
+ return "↑↓ select · enter parent · esc prompt";
157
+ const selected = rows.find((row) => row.key === options.selectedKey);
158
+ let treeHint = "";
159
+ if (selected?.descendantCount) treeHint = selected.expanded ? " · ← collapse" : " · → expand";
160
+ if (!selected?.expanded && selected?.depth) treeHint += " · ← parent";
161
+ return `↑↓ select · enter open · x close${treeHint} · esc prompt`;
162
+ }
163
+
155
164
  /** Pure layout: the cap counts child entries, not physical lines or hidden children. */
156
165
  export function renderDelegateWidget(
157
166
  rows: readonly DelegateRow[],
@@ -162,20 +171,12 @@ export function renderDelegateWidget(
162
171
  options: WidgetRenderOptions = {},
163
172
  ): string[] {
164
173
  if (width <= 0) return [];
165
- const ancestors: readonly AncestorRow[] = options.ancestors ?? [];
166
- if (!ancestors.length && (cap === 0 || !rows.length) && !options.ancestryError) return [];
167
- const gutter: string = options.focused ? " " : "";
168
- const lines: string[] = ancestorLines(ancestors, options, theme);
169
- if (options.ancestryError) lines.push(gutter + theme.fg("warning", widgetText(options.ancestryError)));
174
+ const breadcrumb = options.breadcrumb;
175
+ if (!breadcrumb && (cap === 0 || !rows.length) && !options.ancestryError) return [];
176
+ const lines: string[] = breadcrumb ? [breadcrumbLine(breadcrumb, width, options, theme)] : [];
177
+ if (options.ancestryError) lines.push(GUTTER + theme.fg("warning", widgetText(options.ancestryError)));
170
178
  if (cap > 0 && rows.length) lines.push(...childLines(rows, cap, width, theme, now, options));
171
- if (options.focused)
172
- lines.push(
173
- theme.fg(
174
- "dim",
175
- ancestors.some((ancestor) => ancestor.key === options.selectedKey)
176
- ? "↑↓ select · enter switch · esc prompt"
177
- : "↑↓ select · enter details · esc prompt",
178
- ),
179
- );
179
+ if (options.focused) lines.push(theme.fg("dim", focusedHint(rows, options)));
180
+ else lines.push(theme.fg("dim", "↓ to manage delegates"));
180
181
  return lines.map((line) => truncateToWidth(line, width));
181
182
  }
@@ -1,11 +1,12 @@
1
1
  /** Compact-list state only. Stable keys never confer action authority. */
2
2
  export class WidgetSelection {
3
3
  private keys: readonly string[] = [];
4
- private ancestors = 0;
4
+ private breadcrumb = 0;
5
5
  private cap = 0;
6
6
  private index = 0;
7
7
  private start = 0;
8
8
  active = false;
9
+ readonly expanded = new Set<string>();
9
10
 
10
11
  get selectedKey(): string | undefined {
11
12
  return this.keys[this.index];
@@ -14,11 +15,11 @@ export class WidgetSelection {
14
15
  return this.start;
15
16
  }
16
17
 
17
- update(children: readonly string[], cap: number, ancestors: readonly string[] = []): void {
18
+ update(children: readonly string[], cap: number, breadcrumbKey?: string): void {
18
19
  const selected: string | undefined = this.selectedKey;
19
- const keys: readonly string[] = [...ancestors, ...(cap > 0 ? children : [])];
20
+ const keys: readonly string[] = [...(breadcrumbKey ? [breadcrumbKey] : []), ...(cap > 0 ? children : [])];
20
21
  this.keys = keys;
21
- this.ancestors = ancestors.length;
22
+ this.breadcrumb = breadcrumbKey ? 1 : 0;
22
23
  this.cap = cap;
23
24
  const retained: number = selected === undefined ? -1 : keys.indexOf(selected);
24
25
  this.index = retained >= 0 ? retained : Math.max(0, Math.min(this.index, keys.length - 1));
@@ -29,12 +30,20 @@ export class WidgetSelection {
29
30
  enter(): boolean {
30
31
  if (!this.keys.length) return false;
31
32
  this.active = true;
32
- this.index = Math.max(0, this.ancestors - 1);
33
+ this.index = 0;
33
34
  this.scroll();
34
35
  return true;
35
36
  }
36
37
 
38
+ select(key: string): void {
39
+ const index = this.keys.indexOf(key);
40
+ if (index < 0) return;
41
+ this.index = index;
42
+ this.scroll();
43
+ }
44
+
37
45
  leave(): void {
46
+ this.expanded.clear();
38
47
  this.active = false;
39
48
  this.start = 0;
40
49
  }
@@ -46,13 +55,13 @@ export class WidgetSelection {
46
55
  }
47
56
 
48
57
  private scroll(): void {
49
- const child: number = this.index - this.ancestors;
58
+ const child: number = this.index - this.breadcrumb;
50
59
  if (!this.active || child < 0 || this.cap === 0) {
51
60
  this.start = 0;
52
61
  return;
53
62
  }
54
63
  if (child < this.start) this.start = child;
55
64
  if (child >= this.start + this.cap) this.start = child - this.cap + 1;
56
- this.start = Math.max(0, Math.min(this.start, this.keys.length - this.ancestors - this.cap));
65
+ this.start = Math.max(0, Math.min(this.start, this.keys.length - this.breadcrumb - this.cap));
57
66
  }
58
67
  }
package/src/widget.ts CHANGED
@@ -1,33 +1,35 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { Results } from "./results.ts";
3
- import type { TmuxClient, TmuxWindow } from "./tmux.ts";
4
- import { type VisibleFacts, visibleRecordFacts } from "./tree-view.ts";
2
+ import { isStarted, type Recorded, type Results, type StartedRecorded } from "./results.ts";
3
+ import type { TmuxClient } from "./tmux.ts";
4
+ import { readTreeDisplay } from "./tree-registry.ts";
5
+ import { visibleRecordFacts } from "./tree-view.ts";
5
6
  import { joinActivities } from "./widget-activity-join.ts";
6
- import { type AncestorRow, AncestorView, switchAncestor } from "./widget-ancestors.ts";
7
+ import { type Breadcrumb, BreadcrumbView, switchAncestor } from "./widget-ancestors.ts";
7
8
  import { currentChildWindows } from "./widget-current.ts";
8
- import { type DelegateRow, DirectDelegateView, type MetadataViewport } from "./widget-data.ts";
9
+ import { type DelegateRow, DirectDelegateView, delegateTree } from "./widget-data.ts";
9
10
  import { WidgetFocus } from "./widget-focus.ts";
10
11
  import { LiveActivityReader } from "./widget-live.ts";
11
12
  import { renderDelegateWidget } from "./widget-render.ts";
12
13
  import { WidgetSelection } from "./widget-selection.ts";
13
14
  import { DEFAULT_WIDGET_SETTINGS, type WidgetSettings, widgetSettings } from "./widget-settings.ts";
14
- import { taskSummary } from "./widget-summary.ts";
15
15
  import { titlePublisher } from "./widget-title.ts";
16
- import { listWindows } from "./windows.ts";
16
+ import { listPanes } from "./windows.ts";
17
17
 
18
18
  export const DELEGATE_WIDGET_KEY = "squire-delegates";
19
19
  export const WIDGET_REFRESH_MS = 1000;
20
- type OpenDashboard = (ctx: ExtensionContext, sessionId: string) => Promise<void>;
20
+ type OpenDelegate = (ctx: ExtensionContext, id: string) => Promise<void>;
21
21
 
22
22
  /** Observation and UI only; direct Results/task authority and the configured editor/footer stay untouched. */
23
23
  class Overview {
24
24
  private readonly results: Results;
25
25
  private readonly tmux: TmuxClient;
26
26
  private readonly view = new DirectDelegateView();
27
- private readonly ancestorView = new AncestorView();
27
+ private readonly breadcrumbView = new BreadcrumbView();
28
28
  private readonly live = new LiveActivityReader();
29
- private ancestors: AncestorRow[] = [];
29
+ private breadcrumb: Breadcrumb | undefined;
30
30
  private ancestryError = "";
31
+ private breadcrumbRevision = -1;
32
+ private breadcrumbReadAt = 0;
31
33
  private readonly ctx: ExtensionContext;
32
34
  private readonly selection = new WidgetSelection();
33
35
  private readonly focus: WidgetFocus;
@@ -43,13 +45,15 @@ class Overview {
43
45
  private factsRevision = 0;
44
46
  private layoutRevision = 0;
45
47
  private rows: DelegateRow[] = [];
48
+ private allRows: DelegateRow[] = [];
46
49
  private configuration: WidgetSettings = { ...DEFAULT_WIDGET_SETTINGS };
47
50
 
48
51
  constructor(
49
52
  results: Results,
50
53
  tmux: TmuxClient,
51
54
  ctx: ExtensionContext,
52
- open: OpenDashboard,
55
+ open: OpenDelegate,
56
+ close: OpenDelegate,
53
57
  assertActive: () => void,
54
58
  ) {
55
59
  this.results = results;
@@ -61,10 +65,31 @@ class Overview {
61
65
  changed: (): void => this.changed(),
62
66
  open: async (key: string): Promise<void> => {
63
67
  assertActive();
64
- const ancestor: AncestorRow | undefined = this.ancestors.find((row) => row.key === key);
65
- if (ancestor) return switchAncestor(ctx, ancestor.sessionId, tmux, assertActive, this.live);
66
- if (!this.rows.some((row) => row.key === key)) throw new Error("delegate is no longer visible");
67
- await open(ctx, key);
68
+ if (this.breadcrumb?.key === key)
69
+ return switchAncestor(ctx, this.breadcrumb.parentSessionId, tmux, assertActive, this.live);
70
+ const row = this.rows.find((row) => row.key === key);
71
+ if (!row) throw new Error("delegate is no longer visible");
72
+ await open(ctx, row.launchId);
73
+ },
74
+ close: async (key: string): Promise<void> => {
75
+ assertActive();
76
+ if (this.breadcrumb?.key === key) return ctx.ui.notify("delegate: cannot close an ancestor", "warning");
77
+ const row = this.rows.find((row) => row.key === key);
78
+ if (!row) throw new Error("delegate is no longer visible");
79
+ await close(ctx, row.launchId);
80
+ },
81
+ horizontal: (direction: -1 | 1): void => {
82
+ const row = this.rows.find((candidate) => candidate.key === this.selection.selectedKey);
83
+ if (!row) return;
84
+ const expanded = this.selection.expanded;
85
+ if (direction === -1) {
86
+ if (!expanded.delete(row.key) && row.parentKey) this.selection.select(row.parentKey);
87
+ return;
88
+ }
89
+ if (!row.descendantCount) return;
90
+ if (!expanded.has(row.key)) return void expanded.add(row.key);
91
+ const child = this.rows.find((candidate) => candidate.parentKey === row.key);
92
+ if (child) this.selection.select(child.key);
68
93
  },
69
94
  error: (error: unknown): void =>
70
95
  ctx.ui.notify(`delegate: ${error instanceof Error ? error.message : String(error)}`, "error"),
@@ -95,14 +120,16 @@ class Overview {
95
120
  this.unsubscribe?.();
96
121
  this.focus.stop();
97
122
  this.view.clear();
98
- this.ancestorView.clear();
123
+ this.breadcrumbView.clear();
99
124
  this.ctx.ui.setWidget(DELEGATE_WIDGET_KEY, undefined);
100
125
  }
101
126
 
102
127
  private changed(): void {
103
128
  if (this.stopped) return;
104
129
  this.layoutRevision++;
105
- this.show(this.rows, this.configuration);
130
+ const rows = this.treeRows(this.allRows);
131
+ this.updateSelection(rows, this.configuration);
132
+ this.show(rows, this.configuration);
106
133
  this.queue();
107
134
  }
108
135
 
@@ -138,26 +165,29 @@ class Overview {
138
165
  });
139
166
  }
140
167
 
141
- private async readAncestors(): Promise<void> {
168
+ private async readBreadcrumb(): Promise<void> {
169
+ if (this.breadcrumbRevision === this.factsRevision && Date.now() - this.breadcrumbReadAt < 30_000) return;
170
+ this.breadcrumbRevision = this.factsRevision;
171
+ this.breadcrumbReadAt = Date.now();
142
172
  try {
143
- this.ancestors = await this.ancestorView.read(this.ctx, this.live);
173
+ this.breadcrumb = await this.breadcrumbView.read(this.ctx, this.live);
144
174
  this.ancestryError = "";
145
175
  } catch {
146
- this.ancestors = [];
176
+ this.breadcrumb = undefined;
147
177
  this.ancestryError = "Ancestor information unavailable";
148
178
  }
149
179
  }
150
180
 
151
- private summarize(rows: DelegateRow[], viewport: MetadataViewport, windows: TmuxWindow[] | undefined): void {
152
- const visible: DelegateRow[] = rows.slice(viewport.offset, viewport.offset + viewport.limit);
153
- if (!visible.length) return;
154
- try {
155
- const facts: VisibleFacts = visibleRecordFacts(this.results, this.ctx, 20, false);
156
- for (const row of visible) row.summary = taskSummary(facts, row.sessionId, windows);
157
- } catch {
158
- // Optional descendant facts must not freeze fresh ancestors or direct-child state.
159
- for (const row of visible) row.summary = "tasks: coverage unavailable";
160
- }
181
+ private treeRows(rows: DelegateRow[]): DelegateRow[] {
182
+ return delegateTree(rows, this.ctx.sessionManager.getSessionId(), this.selection.expanded);
183
+ }
184
+
185
+ private updateSelection(rows: DelegateRow[], settings: WidgetSettings): void {
186
+ this.selection.update(
187
+ rows.map((row) => row.key),
188
+ settings.maxEntries,
189
+ this.breadcrumb?.key,
190
+ );
161
191
  }
162
192
 
163
193
  private async refresh(): Promise<void> {
@@ -166,33 +196,43 @@ class Overview {
166
196
  try {
167
197
  const settings: WidgetSettings = this.settings();
168
198
  this.configureCount(settings.maxEntries === 0);
169
- const windows =
170
- settings.maxEntries > 0 && this.view.needsWindows(this.results) ? await listWindows(this.tmux) : [];
199
+ let observed: Results = this.results;
200
+ if (settings.maxEntries > 0) {
201
+ try {
202
+ const records = visibleRecordFacts(this.results, this.ctx, 20, false, readTreeDisplay).records;
203
+ observed = {
204
+ ...this.results,
205
+ list: (): Recorded[] => records,
206
+ running: (): StartedRecorded[] =>
207
+ records
208
+ .filter(isStarted)
209
+ .filter((record) => !record.closed && !record.result && !record.gone && !record.accountingOnly),
210
+ };
211
+ } catch {}
212
+ }
213
+ const listing =
214
+ settings.maxEntries > 0 && this.view.needsWindows(observed) ? await listPanes(this.tmux) : undefined;
215
+ const windows = listing?.windows;
171
216
  if (this.stopped || revision !== this.factsRevision) return;
172
217
  const sessionDir: string = this.ctx.sessionManager.getSessionDir();
173
- const current = await currentChildWindows(this.results, this.tmux, windows, this.live);
174
- await this.readAncestors();
218
+ const current = await currentChildWindows(observed, listing, this.live);
219
+ await this.readBreadcrumb();
175
220
  if (this.stopped || revision !== this.factsRevision) return;
176
- const eligible: DelegateRow[] = this.view.read(
177
- this.results,
178
- windows,
179
- sessionDir,
180
- { offset: 0, limit: 0 },
181
- current,
182
- );
183
- this.selection.update(
184
- eligible.map((row) => row.key),
185
- settings.maxEntries,
186
- this.ancestors.map((row) => row.key),
187
- );
221
+ const eligible: DelegateRow[] = this.view.read(observed, windows, sessionDir, { offset: 0, limit: 0 }, current);
222
+ const projected = this.treeRows(eligible);
223
+ this.updateSelection(projected, settings);
188
224
  const viewport = { offset: this.selection.offset, limit: settings.maxEntries };
189
225
  const layout: number = this.layoutRevision;
190
- const rows: DelegateRow[] = this.view.read(this.results, windows, sessionDir, viewport, current);
191
- this.summarize(rows, viewport, windows);
192
- await joinActivities(rows, viewport, this.results);
226
+ const visibleKeys = new Set(
227
+ projected.slice(viewport.offset, viewport.offset + viewport.limit).map((row) => row.key),
228
+ );
229
+ const allRows = this.view.read(observed, windows, sessionDir, viewport, current, visibleKeys);
230
+ const rows = this.treeRows(allRows);
231
+ await joinActivities(rows, viewport, observed);
193
232
  // Only Results notifications change factsRevision; timer ticks must not
194
233
  // starve a read, and selection changes must discard/requeue its old viewport.
195
234
  if (this.stopped || revision !== this.factsRevision || layout !== this.layoutRevision) return;
235
+ this.allRows = allRows;
196
236
  this.show(rows, settings);
197
237
  } catch {
198
238
  // Optional metadata/UI failures cannot interrupt launch, poll or result processing.
@@ -212,13 +252,13 @@ class Overview {
212
252
  offset: this.selection.offset,
213
253
  selectedKey: this.selection.selectedKey,
214
254
  focused: this.focus.focused,
215
- ancestors: this.ancestors,
255
+ breadcrumb: this.breadcrumb,
216
256
  ancestryError: this.ancestryError,
217
257
  };
218
258
  const signature: string = JSON.stringify([rows, settings, options, Math.floor(Date.now() / 60_000)]);
219
259
  if (signature === this.signature) return;
220
260
  this.signature = signature;
221
- if (!this.ancestors.length && !this.ancestryError && (!rows.length || !settings.maxEntries)) {
261
+ if (!this.breadcrumb && !this.ancestryError && (!rows.length || !settings.maxEntries)) {
222
262
  this.ctx.ui.setWidget(DELEGATE_WIDGET_KEY, undefined);
223
263
  return;
224
264
  }
@@ -245,7 +285,8 @@ export function registerDelegateWidget(
245
285
  pi: ExtensionAPI,
246
286
  results: Results,
247
287
  tmux: TmuxClient,
248
- open: OpenDashboard,
288
+ open: OpenDelegate,
289
+ close: OpenDelegate,
249
290
  assertActive: () => void,
250
291
  ): void {
251
292
  let overview: Overview | undefined;
@@ -261,7 +302,7 @@ export function registerDelegateWidget(
261
302
  publisher = titlePublisher(ctx.sessionManager.getSessionId());
262
303
  publisher.publish(pi.getSessionName());
263
304
  if (ctx.hasUI && ctx.mode === "tui") {
264
- overview = new Overview(results, tmux, ctx, open, assertActive);
305
+ overview = new Overview(results, tmux, ctx, open, close, assertActive);
265
306
  overview.start();
266
307
  }
267
308
  });
package/src/windows.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Reading tmux's window list against the delegation records.
2
2
  import type { StartedRecorded, WindowState } from "./results.ts";
3
- import type { TmuxClient, TmuxWindow } from "./tmux.ts";
3
+ import type { PaneListing, TmuxClient, TmuxWindow } from "./tmux.ts";
4
4
 
5
5
  /** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
6
6
  export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
@@ -11,6 +11,14 @@ export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | unde
11
11
  }
12
12
  }
13
13
 
14
+ export async function listPanes(tmux: TmuxClient): Promise<PaneListing | undefined> {
15
+ try {
16
+ return await tmux.listPanes();
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
14
22
  /** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
15
23
  export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
16
24
  return windows.find((window) => window.id === record.windowId && window.name === record.windowName);