pi-squire 0.20.0 → 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 +126 -61
- package/README.md +59 -90
- package/package.json +2 -2
- package/src/agents.ts +1 -1
- package/src/fork.ts +18 -1
- package/src/index.ts +7 -23
- package/src/navigator-view.ts +8 -75
- package/src/navigator.ts +29 -148
- package/src/result-sessions.ts +26 -0
- 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/dashboard.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Presentation only. The navigator resolves these opaque ids and owns every action/refusal.
|
|
2
|
-
import { type Component, isKeyRelease, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { type WidgetTheme, widgetText } from "./widget-render.ts";
|
|
4
|
-
|
|
5
|
-
export type DashboardRow = { id: string; label: string; details: readonly string[]; depth?: number };
|
|
6
|
-
export type DashboardIntent =
|
|
7
|
-
| { action: "switch" | "close" | "peek" | "resume"; id: string }
|
|
8
|
-
| { action: "new" }
|
|
9
|
-
| { action: "close-all" }
|
|
10
|
-
| undefined;
|
|
11
|
-
export type DashboardPosition = { id?: string; detail: boolean; offset: number; branchList?: boolean };
|
|
12
|
-
type DashboardOptions = {
|
|
13
|
-
items: readonly DashboardRow[];
|
|
14
|
-
root?: boolean;
|
|
15
|
-
branch?: string;
|
|
16
|
-
position: DashboardPosition;
|
|
17
|
-
detailBack: "list" | "exit";
|
|
18
|
-
theme: WidgetTheme;
|
|
19
|
-
done(result: DashboardIntent): void;
|
|
20
|
-
redraw(): void;
|
|
21
|
-
rows(): number;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
function label(row: DashboardRow): string {
|
|
25
|
-
return " ".repeat(Math.max(0, Math.min(64, row.depth ?? 0))) + widgetText(row.label);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** One screen used by command and widget entry, with durable position across action dialogs. */
|
|
29
|
-
export class Dashboard implements Component {
|
|
30
|
-
private readonly options: DashboardOptions;
|
|
31
|
-
|
|
32
|
-
constructor(options: DashboardOptions) {
|
|
33
|
-
this.options = options;
|
|
34
|
-
const found: boolean = options.items.some((row) => row.id === options.position.id);
|
|
35
|
-
if (!found) {
|
|
36
|
-
options.position.id = options.items[0]?.id;
|
|
37
|
-
options.position.offset = 0;
|
|
38
|
-
options.position.detail = false;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
invalidate(): void {}
|
|
43
|
-
|
|
44
|
-
private selected(): DashboardRow | undefined {
|
|
45
|
-
return this.options.items.find((row) => row.id === this.options.position.id);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
handleInput(data: string): void {
|
|
49
|
-
if (isKeyRelease(data)) return;
|
|
50
|
-
const { position, done } = this.options;
|
|
51
|
-
if (matchesKey(data, "escape")) {
|
|
52
|
-
if (position.detail && (this.options.detailBack === "list" || position.branchList)) {
|
|
53
|
-
position.detail = false;
|
|
54
|
-
position.offset = 0;
|
|
55
|
-
} else done(undefined);
|
|
56
|
-
} else if (matchesKey(data, "b") && this.options.branch !== undefined) {
|
|
57
|
-
position.branchList = true;
|
|
58
|
-
position.detail = false;
|
|
59
|
-
position.offset = 0;
|
|
60
|
-
} else if (matchesKey(data, "n")) done({ action: "new" });
|
|
61
|
-
else if (matchesKey(data, "a") && this.options.root) done({ action: "close-all" });
|
|
62
|
-
else if (matchesKey(data, "up")) this.move(-1);
|
|
63
|
-
else if (matchesKey(data, "down")) this.move(1);
|
|
64
|
-
else this.action(data);
|
|
65
|
-
this.options.redraw();
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private action(data: string): void {
|
|
69
|
-
const item: DashboardRow | undefined = this.selected();
|
|
70
|
-
if (!item) return;
|
|
71
|
-
const { position, done } = this.options;
|
|
72
|
-
if (matchesKey(data, "enter")) {
|
|
73
|
-
if (position.detail) done({ action: "switch", id: item.id });
|
|
74
|
-
else {
|
|
75
|
-
position.detail = true;
|
|
76
|
-
position.offset = 0;
|
|
77
|
-
}
|
|
78
|
-
} else if (matchesKey(data, "p")) done({ action: "peek", id: item.id });
|
|
79
|
-
else if (matchesKey(data, "x")) done({ action: "close", id: item.id });
|
|
80
|
-
else if (matchesKey(data, "r")) done({ action: "resume", id: item.id });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private move(direction: -1 | 1): void {
|
|
84
|
-
const { items, position } = this.options;
|
|
85
|
-
if (position.detail) {
|
|
86
|
-
const max: number = Math.max(0, (this.selected()?.details.length ?? 0) - this.height());
|
|
87
|
-
position.offset = Math.max(0, Math.min(max, position.offset + direction));
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const index: number = items.findIndex((row) => row.id === position.id);
|
|
91
|
-
position.id = items[Math.max(0, Math.min(items.length - 1, index + direction))]?.id;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
private height(): number {
|
|
95
|
-
return Math.max(1, this.options.rows() - 6);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
render(width: number): string[] {
|
|
99
|
-
const { items, position, theme } = this.options;
|
|
100
|
-
const fit = (text: string): string => truncateToWidth(text, Math.max(0, width));
|
|
101
|
-
const item: DashboardRow | undefined = this.selected();
|
|
102
|
-
if (!item)
|
|
103
|
-
return [theme.bold(this.heading()), "", `No open delegates · n new · esc close${this.closeAllHint()}`].map(fit);
|
|
104
|
-
if (position.detail) return this.details(item).map(fit);
|
|
105
|
-
const index: number = items.indexOf(item);
|
|
106
|
-
const start: number = Math.max(0, index - this.height() + 1);
|
|
107
|
-
const rows: string[] = items
|
|
108
|
-
.slice(start, start + this.height())
|
|
109
|
-
.map((row) => (row.id === item.id ? theme.fg("accent", `▸ ${label(row)}`) : ` ${label(row)}`));
|
|
110
|
-
return [
|
|
111
|
-
theme.bold(this.heading()),
|
|
112
|
-
"",
|
|
113
|
-
...rows,
|
|
114
|
-
`${index + 1}/${items.length}`,
|
|
115
|
-
theme.fg(
|
|
116
|
-
"dim",
|
|
117
|
-
`↑↓ select · enter details · p peek · x close · r resume · n new · esc leave${this.closeAllHint()}`,
|
|
118
|
-
),
|
|
119
|
-
].map(fit);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private heading(): string {
|
|
123
|
-
return this.options.branch === undefined ? "Delegates" : `Delegate branch: ${widgetText(this.options.branch)}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private closeAllHint(): string {
|
|
127
|
-
if (!this.options.root) return "";
|
|
128
|
-
return this.options.branch === undefined ? " · a close all subtrees" : " · a close ALL ROOT subtrees";
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private details(item: DashboardRow): string[] {
|
|
132
|
-
const { position, theme } = this.options;
|
|
133
|
-
return [
|
|
134
|
-
theme.bold(`Delegate: ${widgetText(item.label)}`),
|
|
135
|
-
"",
|
|
136
|
-
...item.details.slice(position.offset, position.offset + this.height()).map(widgetText),
|
|
137
|
-
"",
|
|
138
|
-
theme.fg(
|
|
139
|
-
"dim",
|
|
140
|
-
`↑↓ scroll · enter switch · p peek · x close · r resume · n new · esc back${this.options.branch === undefined ? "" : " · b branch list"}${this.closeAllHint()}`,
|
|
141
|
-
),
|
|
142
|
-
];
|
|
143
|
-
}
|
|
144
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
export type ProcessIdentity = { pid: number; processStart: string };
|
|
4
|
-
export type Liveness = "live" | "dead" | "unknown";
|
|
5
|
-
|
|
6
|
-
export function processStart(pid: number): Promise<string | undefined> {
|
|
7
|
-
return new Promise((resolve) => {
|
|
8
|
-
// lstart is formatted text: publisher and reader must use the same locale/time zone.
|
|
9
|
-
execFile(
|
|
10
|
-
"ps",
|
|
11
|
-
["-o", "lstart=", "-p", String(pid)],
|
|
12
|
-
{ timeout: 1000, maxBuffer: 4096, env: { ...process.env, LC_ALL: "C", TZ: "UTC" } },
|
|
13
|
-
(error, stdout) => {
|
|
14
|
-
resolve(error ? undefined : stdout.trim() || undefined);
|
|
15
|
-
},
|
|
16
|
-
);
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Only ESRCH or a successfully observed different start token proves a dead generation. */
|
|
21
|
-
export async function processLiveness(owner: ProcessIdentity): Promise<Liveness> {
|
|
22
|
-
try {
|
|
23
|
-
process.kill(owner.pid, 0);
|
|
24
|
-
} catch (error) {
|
|
25
|
-
if ((error as NodeJS.ErrnoException).code === "ESRCH") return "dead";
|
|
26
|
-
return "unknown";
|
|
27
|
-
}
|
|
28
|
-
const start: string | undefined = await processStart(owner.pid);
|
|
29
|
-
if (start === undefined) return "unknown";
|
|
30
|
-
return start === owner.processStart ? "live" : "dead";
|
|
31
|
-
}
|
package/src/widget-summary.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// Counts delegated tasks (launch ids), not stable sessions or registry cap charges.
|
|
2
|
-
import { isStarted, type Recorded, sessionIdOf } from "./results.ts";
|
|
3
|
-
import type { TmuxWindow } from "./tmux.ts";
|
|
4
|
-
import type { VisibleFact, VisibleFacts } from "./tree-view.ts";
|
|
5
|
-
import { windowState } from "./windows.ts";
|
|
6
|
-
|
|
7
|
-
type Outcome = "done" | "failed" | "stopped" | "missing" | "running" | "unknown";
|
|
8
|
-
function outcome(fact: VisibleFact, windows: TmuxWindow[] | undefined): Outcome {
|
|
9
|
-
const record: Recorded | undefined = fact.semantic;
|
|
10
|
-
if (!record || !isStarted(record) || record.accountingOnly) return "unknown";
|
|
11
|
-
if (record.result) {
|
|
12
|
-
const status: string = record.result.status;
|
|
13
|
-
return status === "done" || status === "failed" || status === "stopped" ? status : "unknown";
|
|
14
|
-
}
|
|
15
|
-
if (record.closed || record.gone) return "missing";
|
|
16
|
-
if (fact.entry?.finished || fact.entry?.closed || fact.entry?.closing || fact.entry?.gone) return "unknown";
|
|
17
|
-
return windowState(windows, record) === "open" ? "running" : "unknown";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function below(facts: VisibleFact[], sessionId: string): VisibleFact[] {
|
|
21
|
-
const sessions: Set<string> = new Set([sessionId]);
|
|
22
|
-
let changed = true;
|
|
23
|
-
while (changed) {
|
|
24
|
-
changed = false;
|
|
25
|
-
for (const { record } of facts) {
|
|
26
|
-
if (!isStarted(record) || !record.identity || !sessions.has(record.identity.parent)) continue;
|
|
27
|
-
const id: string = sessionIdOf(record);
|
|
28
|
-
if (!sessions.has(id)) {
|
|
29
|
-
sessions.add(id);
|
|
30
|
-
changed = true;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return facts.filter(({ record }) => isStarted(record) && record.identity && sessions.has(record.identity.parent));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function taskSummary(view: VisibleFacts, sessionId: string, windows: TmuxWindow[] | undefined): string {
|
|
38
|
-
const descendants: VisibleFact[] = below(view.facts, sessionId);
|
|
39
|
-
if (!descendants.length) return view.complete ? "" : "tasks: coverage unavailable";
|
|
40
|
-
const counts: Map<Outcome, number> = new Map();
|
|
41
|
-
const seen: Set<string> = new Set();
|
|
42
|
-
for (const fact of descendants) {
|
|
43
|
-
if (seen.has(fact.record.id)) continue;
|
|
44
|
-
seen.add(fact.record.id);
|
|
45
|
-
const state: Outcome = outcome(fact, windows);
|
|
46
|
-
counts.set(state, (counts.get(state) ?? 0) + 1);
|
|
47
|
-
}
|
|
48
|
-
const order: Outcome[] = ["failed", "stopped", "missing", "running", "unknown", "done"];
|
|
49
|
-
const labels: string[] = order.flatMap((state) => (counts.has(state) ? [`${counts.get(state)} ${state}`] : []));
|
|
50
|
-
return `${seen.size} task${seen.size === 1 ? "" : "s"}: ${labels.join(" · ")}${view.complete ? "" : " · partial coverage"}`;
|
|
51
|
-
}
|