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.
- package/CHANGELOG.md +115 -61
- package/README.md +55 -89
- package/package.json +2 -2
- package/src/agents.ts +1 -1
- package/src/index.ts +7 -23
- package/src/navigator-view.ts +8 -75
- package/src/navigator.ts +29 -148
- package/src/results.ts +2 -2
- package/src/tmux.ts +37 -0
- package/src/tracking.ts +3 -2
- package/src/tree-registry.ts +4 -0
- package/src/tree-view.ts +2 -1
- package/src/widget-activity-runtime.ts +10 -27
- package/src/widget-ancestors.ts +53 -41
- package/src/widget-current.ts +21 -20
- package/src/widget-data.ts +35 -2
- package/src/widget-focus.ts +21 -11
- package/src/widget-live.ts +7 -16
- package/src/widget-render.ts +72 -71
- package/src/widget-selection.ts +16 -7
- package/src/widget.ts +94 -53
- package/src/windows.ts +9 -1
- package/src/dashboard.ts +0 -144
- package/src/widget-live-process.ts +0 -31
- package/src/widget-summary.ts +0 -51
package/src/widget-render.ts
CHANGED
|
@@ -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 {
|
|
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 ? "" : `
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
62
|
-
export function activityLine(
|
|
63
|
-
row
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
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
|
|
166
|
-
if (!
|
|
167
|
-
const
|
|
168
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/widget-selection.ts
CHANGED
|
@@ -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
|
|
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,
|
|
18
|
+
update(children: readonly string[], cap: number, breadcrumbKey?: string): void {
|
|
18
19
|
const selected: string | undefined = this.selectedKey;
|
|
19
|
-
const keys: readonly string[] = [...
|
|
20
|
+
const keys: readonly string[] = [...(breadcrumbKey ? [breadcrumbKey] : []), ...(cap > 0 ? children : [])];
|
|
20
21
|
this.keys = keys;
|
|
21
|
-
this.
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
|
3
|
-
import type { TmuxClient
|
|
4
|
-
import {
|
|
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
|
|
7
|
+
import { type Breadcrumb, BreadcrumbView, switchAncestor } from "./widget-ancestors.ts";
|
|
7
8
|
import { currentChildWindows } from "./widget-current.ts";
|
|
8
|
-
import { type DelegateRow, DirectDelegateView,
|
|
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 {
|
|
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
|
|
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
|
|
27
|
+
private readonly breadcrumbView = new BreadcrumbView();
|
|
28
28
|
private readonly live = new LiveActivityReader();
|
|
29
|
-
private
|
|
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:
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
173
|
+
this.breadcrumb = await this.breadcrumbView.read(this.ctx, this.live);
|
|
144
174
|
this.ancestryError = "";
|
|
145
175
|
} catch {
|
|
146
|
-
this.
|
|
176
|
+
this.breadcrumb = undefined;
|
|
147
177
|
this.ancestryError = "Ancestor information unavailable";
|
|
148
178
|
}
|
|
149
179
|
}
|
|
150
180
|
|
|
151
|
-
private
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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(
|
|
174
|
-
await this.
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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);
|