@smithers-orchestrator/pi-plugin 0.16.9

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.
@@ -0,0 +1,72 @@
1
+ import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { DevToolsStore } from "../runtime/DevToolsStore.js";
3
+
4
+ type Theme = {
5
+ fg?: (color: string, value: string) => string;
6
+ bold?: (value: string) => string;
7
+ };
8
+
9
+ function paint(theme: Theme, color: string, value: string) {
10
+ return theme.fg ? theme.fg(color, value) : value;
11
+ }
12
+
13
+ export class FrameScrubber {
14
+ constructor(private readonly store: DevToolsStore) {}
15
+
16
+ handleInput(data: string) {
17
+ if (matchesKey(data, "left") || data === "\x1b[D" || matchesKey(data, ",")) {
18
+ void this.store.scrubTo(Math.max(0, this.store.displayedFrameNo - 1));
19
+ return true;
20
+ }
21
+ if (matchesKey(data, "right") || data === "\x1b[C" || matchesKey(data, ".")) {
22
+ void this.store.scrubTo(Math.min(this.store.latestFrameNo, this.store.displayedFrameNo + 1));
23
+ return true;
24
+ }
25
+ if (matchesKey(data, "home")) {
26
+ void this.store.scrubTo(0);
27
+ return true;
28
+ }
29
+ if (matchesKey(data, "end")) {
30
+ this.store.returnToLive();
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ render(width: number, theme: Theme) {
37
+ const W = Math.max(24, width);
38
+ const latest = Math.max(0, this.store.latestFrameNo);
39
+ const current = Math.min(this.store.displayedFrameNo, latest);
40
+ const barWidth = Math.max(10, W - 24);
41
+ const position = latest <= 0 ? 0 : Math.round((current / latest) * (barWidth - 1));
42
+ const chars = Array.from({ length: barWidth }, (_, index) => (index === position ? "|" : "-"));
43
+ const mode = this.store.mode.kind === "historical" ? paint(theme, "warning", "historical") : paint(theme, "success", "live");
44
+ const lines = [
45
+ truncateToWidth(
46
+ ` frame ${String(current).padStart(3)} / ${String(latest).padEnd(3)} ${paint(theme, "border", chars.join(""))} ${mode}`,
47
+ W,
48
+ ),
49
+ ];
50
+ if (this.store.mode.kind === "historical") {
51
+ const running =
52
+ this.store.runningNodeCount > 0
53
+ ? ` ${this.store.runningNodeCount} running at this frame.`
54
+ : "";
55
+ lines.push(
56
+ truncateToWidth(
57
+ paint(theme, "warning", ` viewing stale frame ${current}; live has ${latest}.${running}`),
58
+ W,
59
+ ),
60
+ );
61
+ }
62
+ if (this.store.scrubError || this.store.rewindError) {
63
+ lines.push(
64
+ truncateToWidth(
65
+ paint(theme, "error", ` ${(this.store.rewindError ?? this.store.scrubError)?.message ?? "frame error"}`),
66
+ W,
67
+ ),
68
+ );
69
+ }
70
+ return lines;
71
+ }
72
+ }
@@ -0,0 +1,144 @@
1
+ import { truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { DevToolsStore } from "../runtime/DevToolsStore.js";
3
+
4
+ type Theme = {
5
+ fg?: (color: string, value: string) => string;
6
+ bold?: (value: string) => string;
7
+ };
8
+
9
+ function paint(theme: Theme, color: string, value: string) {
10
+ return theme.fg ? theme.fg(color, value) : value;
11
+ }
12
+
13
+ function bold(theme: Theme, value: string) {
14
+ return theme.bold ? theme.bold(value) : value;
15
+ }
16
+
17
+ function stateColor(status: string) {
18
+ switch (status) {
19
+ case "running":
20
+ return "accent";
21
+ case "finished":
22
+ return "success";
23
+ case "failed":
24
+ return "error";
25
+ case "cancelled":
26
+ return "dim";
27
+ case "waiting-approval":
28
+ return "warning";
29
+ default:
30
+ return "muted";
31
+ }
32
+ }
33
+
34
+ function heartbeatColor(ageMs: number, heartbeatMs: number) {
35
+ const interval = Math.max(heartbeatMs, 1);
36
+ if (!Number.isFinite(ageMs)) {
37
+ return "error";
38
+ }
39
+ if (ageMs <= interval * 2) {
40
+ return "success";
41
+ }
42
+ if (ageMs <= interval * 5) {
43
+ return "warning";
44
+ }
45
+ return "error";
46
+ }
47
+
48
+ function numberField(value: Record<string, unknown> | undefined, keys: string[]) {
49
+ for (const key of keys) {
50
+ const raw = value?.[key];
51
+ if (typeof raw === "number") {
52
+ return raw;
53
+ }
54
+ if (typeof raw === "string") {
55
+ const parsed = Number(raw);
56
+ if (Number.isFinite(parsed)) {
57
+ return parsed;
58
+ }
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function stringField(value: Record<string, unknown> | undefined, keys: string[]) {
65
+ for (const key of keys) {
66
+ const raw = value?.[key];
67
+ if (typeof raw === "string" && raw.length > 0) {
68
+ return raw;
69
+ }
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ function dateMs(value: Record<string, unknown> | undefined, msKeys: string[], isoKeys: string[]) {
75
+ const ms = numberField(value, msKeys);
76
+ if (ms !== undefined) {
77
+ return ms;
78
+ }
79
+ const iso = stringField(value, isoKeys);
80
+ if (!iso) {
81
+ return undefined;
82
+ }
83
+ const parsed = Date.parse(iso);
84
+ return Number.isFinite(parsed) ? parsed : undefined;
85
+ }
86
+
87
+ export class Header {
88
+ constructor(
89
+ private readonly store: DevToolsStore,
90
+ private readonly workflowName = "workflow",
91
+ ) {}
92
+
93
+ render(width: number, theme: Theme) {
94
+ const W = Math.max(40, width);
95
+ const runId = this.store.runId ?? "no-run";
96
+ const state = this.store.runStatus;
97
+ const runState = this.store.runStateView;
98
+ const engineHeartbeatMs =
99
+ numberField(runState, ["engineHeartbeatMs", "engine_heartbeat_ms"]) ?? 1_000;
100
+ const engineLastMs =
101
+ dateMs(
102
+ runState,
103
+ ["engineHeartbeatAtMs", "engine_heartbeat_at_ms"],
104
+ ["engineHeartbeatAt", "engine_heartbeat_at"],
105
+ ) ?? this.store.lastEventAt?.getTime();
106
+ const sandboxHeartbeatMs =
107
+ numberField(runState, [
108
+ "viewersHeartbeatMs",
109
+ "viewers_heartbeat_ms",
110
+ "uiHeartbeatMs",
111
+ "ui_heartbeat_ms",
112
+ ]) ?? engineHeartbeatMs;
113
+ const sandboxLastMs = dateMs(
114
+ runState,
115
+ ["viewersHeartbeatAtMs", "viewers_heartbeat_at_ms", "uiHeartbeatAtMs", "ui_heartbeat_at_ms"],
116
+ ["viewersHeartbeatAt", "viewers_heartbeat_at", "uiHeartbeatAt", "ui_heartbeat_at"],
117
+ );
118
+ const now = Date.now();
119
+ const engineAge = engineLastMs === undefined ? Number.POSITIVE_INFINITY : now - engineLastMs;
120
+ const sandboxAge = sandboxLastMs === undefined ? Number.POSITIVE_INFINITY : now - sandboxLastMs;
121
+ const runStateLabel = stringField(runState, ["state"]);
122
+ const connection =
123
+ this.store.connectionState.kind === "streaming"
124
+ ? ""
125
+ : ` ${paint(theme, "warning", this.store.connectionState.kind)}`;
126
+ const left = [
127
+ paint(theme, stateColor(state), bold(theme, state.toUpperCase())),
128
+ paint(theme, "muted", this.workflowName),
129
+ paint(theme, "dim", runId.slice(0, 12)),
130
+ runStateLabel ? paint(theme, "muted", runStateLabel) : "",
131
+ ].filter(Boolean).join(" ");
132
+ const right = [
133
+ `${paint(theme, heartbeatColor(engineAge, engineHeartbeatMs), "eng")}:${Math.max(0, Math.floor(engineAge / 1_000))}s`,
134
+ `${paint(theme, heartbeatColor(sandboxAge, sandboxHeartbeatMs), "box")}:${Number.isFinite(sandboxAge) ? Math.max(0, Math.floor(sandboxAge / 1_000)) : "--"}s`,
135
+ paint(theme, "dim", `seq ${this.store.seq}`),
136
+ connection,
137
+ ].filter(Boolean).join(" ");
138
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
139
+ const plainLeft = left.replace(ansiPattern, "");
140
+ const plainRight = right.replace(ansiPattern, "");
141
+ const gap = Math.max(1, W - plainLeft.length - plainRight.length - 2);
142
+ return [truncateToWidth(` ${left}${" ".repeat(gap)}${right} `, W)];
143
+ }
144
+ }
@@ -0,0 +1,221 @@
1
+ import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { DevToolsNode } from "@smithers-orchestrator/protocol";
3
+ import type { DevToolsStore } from "../runtime/DevToolsStore.js";
4
+
5
+ type Theme = {
6
+ fg?: (color: string, value: string) => string;
7
+ bold?: (value: string) => string;
8
+ };
9
+
10
+ type InspectorTab = "output" | "diff" | "logs";
11
+
12
+ function paint(theme: Theme, color: string, value: string) {
13
+ return theme.fg ? theme.fg(color, value) : value;
14
+ }
15
+
16
+ function bold(theme: Theme, value: string) {
17
+ return theme.bold ? theme.bold(value) : value;
18
+ }
19
+
20
+ function compact(value: unknown) {
21
+ if (typeof value === "string") {
22
+ return value;
23
+ }
24
+ try {
25
+ return JSON.stringify(value, null, 2);
26
+ } catch {
27
+ return String(value);
28
+ }
29
+ }
30
+
31
+ function nodeState(node: DevToolsNode) {
32
+ const raw = node.props.state;
33
+ return typeof raw === "string" ? raw : "unknown";
34
+ }
35
+
36
+ function errorText(node: DevToolsNode) {
37
+ const keys = ["error", "errors", "failure", "exception"];
38
+ for (const key of keys) {
39
+ const value = node.props[key];
40
+ if (value !== undefined) {
41
+ return compact(value);
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ function firstPresent(node: DevToolsNode, keys: string[]) {
48
+ for (const key of keys) {
49
+ if (node.props[key] !== undefined) {
50
+ return node.props[key];
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ function renderJsonLines(value: unknown, fallback: string) {
57
+ const text = value === undefined ? fallback : compact(value);
58
+ return text.split("\n").map((line) => ` ${line}`);
59
+ }
60
+
61
+ function toolCalls(node: DevToolsNode) {
62
+ const value = firstPresent(node, ["toolCalls", "tool_calls", "tools"]);
63
+ if (!Array.isArray(value)) {
64
+ return [];
65
+ }
66
+ return value
67
+ .map((item, index) => {
68
+ if (typeof item !== "object" || item === null) {
69
+ return undefined;
70
+ }
71
+ const record = item as Record<string, unknown>;
72
+ const name =
73
+ record.name ?? record.tool ?? record.toolName ?? record.function ?? `tool-call-${index + 1}`;
74
+ const status = record.status ?? record.state;
75
+ const effect = record.sideEffect ?? record.side_effect ?? record.effect;
76
+ return [name, status, effect].filter((part) => typeof part === "string" && part.length > 0).join(" ");
77
+ })
78
+ .filter((item): item is string => Boolean(item));
79
+ }
80
+
81
+ export class NodeInspector {
82
+ private tab: InspectorTab = "output";
83
+ private scrollOffset = 0;
84
+
85
+ constructor(private readonly store: DevToolsStore) {}
86
+
87
+ handleInput(data: string) {
88
+ if (matchesKey(data, "1")) {
89
+ this.tab = "output";
90
+ this.scrollOffset = 0;
91
+ return "handled";
92
+ }
93
+ if (matchesKey(data, "2")) {
94
+ this.tab = "diff";
95
+ this.scrollOffset = 0;
96
+ return "handled";
97
+ }
98
+ if (matchesKey(data, "3")) {
99
+ this.tab = "logs";
100
+ this.scrollOffset = 0;
101
+ return "handled";
102
+ }
103
+ if (matchesKey(data, "tab") || matchesKey(data, "]")) {
104
+ this.nextTab(1);
105
+ return "handled";
106
+ }
107
+ if (matchesKey(data, "[")) {
108
+ this.nextTab(-1);
109
+ return "handled";
110
+ }
111
+ if (matchesKey(data, "j") || data === "\x1b[B") {
112
+ this.scrollOffset += 1;
113
+ return "handled";
114
+ }
115
+ if (matchesKey(data, "k") || data === "\x1b[A") {
116
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
117
+ return "handled";
118
+ }
119
+ if (matchesKey(data, "g")) {
120
+ this.scrollOffset = 0;
121
+ return "handled";
122
+ }
123
+ return "unhandled";
124
+ }
125
+
126
+ render(width: number, height: number, theme: Theme) {
127
+ const W = Math.max(28, width);
128
+ const H = Math.max(4, height);
129
+ const node = this.store.selectedNode;
130
+ if (!node) {
131
+ return this.pad([paint(theme, "muted", " select a node to inspect")], H);
132
+ }
133
+
134
+ const lines: string[] = [];
135
+ const title = `${node.task?.nodeId ?? node.name} ${nodeState(node)}`;
136
+ lines.push(truncateToWidth(` ${paint(theme, "accent", bold(theme, title))}`, W));
137
+
138
+ const error = errorText(node);
139
+ if (error) {
140
+ lines.push(truncateToWidth(` ${paint(theme, "error", error.split("\n")[0] ?? "error")}`, W));
141
+ }
142
+
143
+ if (this.store.isGhost) {
144
+ const unmounted = this.store.selectedGhostRecord?.unmountedFrameNo;
145
+ lines.push(
146
+ truncateToWidth(
147
+ paint(
148
+ theme,
149
+ "warning",
150
+ ` ghost: node no longer mounted${unmounted === undefined ? "" : ` at frame ${unmounted}`}`,
151
+ ),
152
+ W,
153
+ ),
154
+ );
155
+ }
156
+
157
+ const tabs = (["output", "diff", "logs"] as InspectorTab[])
158
+ .map((tab, index) => {
159
+ const label = `${index + 1}:${tab}`;
160
+ return tab === this.tab ? paint(theme, "accent", bold(theme, `[${label}]`)) : paint(theme, "dim", label);
161
+ })
162
+ .join(" ");
163
+ lines.push(truncateToWidth(` ${tabs}`, W));
164
+
165
+ const body = this.bodyLines(node);
166
+ const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + Math.max(1, H - lines.length));
167
+ for (const line of visibleBody) {
168
+ lines.push(truncateToWidth(line, W));
169
+ }
170
+ return this.pad(lines, H).slice(0, H);
171
+ }
172
+
173
+ private bodyLines(node: DevToolsNode) {
174
+ const calls = toolCalls(node);
175
+ const taskLines = [
176
+ ` task.nodeId: ${node.task?.nodeId ?? "-"}`,
177
+ ` task.kind: ${node.task?.kind ?? "-"}`,
178
+ ` task.agent: ${node.task?.agent ?? "-"}`,
179
+ ` task.iteration: ${node.task?.iteration ?? 0}`,
180
+ ];
181
+ const callLines = calls.length > 0 ? ["", " tool calls:", ...calls.map((call) => ` - ${call}`)] : [];
182
+ switch (this.tab) {
183
+ case "output":
184
+ return [
185
+ ...taskLines,
186
+ ...callLines,
187
+ "",
188
+ " output:",
189
+ ...renderJsonLines(firstPresent(node, ["output", "row", "result", "value"]), " (no output captured)"),
190
+ ];
191
+ case "diff":
192
+ return [
193
+ ...taskLines,
194
+ "",
195
+ " diff:",
196
+ ...renderJsonLines(firstPresent(node, ["diff", "patches", "changes"]), " (no diff captured)"),
197
+ ];
198
+ case "logs":
199
+ return [
200
+ ...taskLines,
201
+ "",
202
+ " logs:",
203
+ ...renderJsonLines(firstPresent(node, ["logs", "log", "stdout", "stderr"]), " (no logs captured)"),
204
+ ];
205
+ }
206
+ }
207
+
208
+ private nextTab(delta: number) {
209
+ const tabs: InspectorTab[] = ["output", "diff", "logs"];
210
+ const current = tabs.indexOf(this.tab);
211
+ this.tab = tabs[(current + delta + tabs.length) % tabs.length];
212
+ this.scrollOffset = 0;
213
+ }
214
+
215
+ private pad(lines: string[], height: number) {
216
+ while (lines.length < height) {
217
+ lines.push("");
218
+ }
219
+ return lines;
220
+ }
221
+ }
@@ -0,0 +1,232 @@
1
+ import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { DevToolsStore } from "../runtime/DevToolsStore.js";
3
+ import type { DevToolsClient } from "../runtime/DevToolsClient.js";
4
+ import { FrameScrubber } from "./FrameScrubber.js";
5
+ import { Header } from "./Header.js";
6
+ import { NodeInspector } from "./NodeInspector.js";
7
+ import { RunTree } from "./RunTree.js";
8
+
9
+ type Theme = {
10
+ fg?: (color: string, value: string) => string;
11
+ bold?: (value: string) => string;
12
+ };
13
+
14
+ type FocusPane = "tree" | "inspector" | "scrubber";
15
+
16
+ type RunInspectorOptions = {
17
+ workflowName?: string;
18
+ onClose?: () => void;
19
+ onNotify?: (message: string, level?: "info" | "warning" | "error") => void;
20
+ };
21
+
22
+ function paint(theme: Theme, color: string, value: string) {
23
+ return theme.fg ? theme.fg(color, value) : value;
24
+ }
25
+
26
+ function stripAnsi(value: string) {
27
+ return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
28
+ }
29
+
30
+ function padRight(value: string, width: number) {
31
+ const plain = stripAnsi(value);
32
+ return plain.length >= width ? value : value + " ".repeat(width - plain.length);
33
+ }
34
+
35
+ function joinPanes(left: string, right: string, leftWidth: number, rightWidth: number) {
36
+ return `${padRight(truncateToWidth(left, leftWidth), leftWidth)} ${truncateToWidth(right, rightWidth)}`;
37
+ }
38
+
39
+ export class RunInspector {
40
+ private readonly header: Header;
41
+ private readonly scrubber: FrameScrubber;
42
+ private readonly tree: RunTree;
43
+ private readonly inspector: NodeInspector;
44
+ private readonly onClose: () => void;
45
+ private readonly onNotify: (message: string, level?: "info" | "warning" | "error") => void;
46
+ private focus: FocusPane = "tree";
47
+ private cachedLines: string[] | undefined;
48
+ private cachedWidth = 0;
49
+
50
+ constructor(
51
+ private readonly store: DevToolsStore,
52
+ private readonly client: DevToolsClient,
53
+ options: RunInspectorOptions = {},
54
+ ) {
55
+ this.header = new Header(store, options.workflowName);
56
+ this.scrubber = new FrameScrubber(store);
57
+ this.tree = new RunTree(store);
58
+ this.inspector = new NodeInspector(store);
59
+ this.onClose = options.onClose ?? (() => undefined);
60
+ this.onNotify = options.onNotify ?? (() => undefined);
61
+ store.subscribe(() => this.invalidate());
62
+ }
63
+
64
+ handleInput(data: string) {
65
+ this.invalidate();
66
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
67
+ this.onClose();
68
+ return;
69
+ }
70
+ if (matchesKey(data, "tab")) {
71
+ this.cycleFocus(1);
72
+ return;
73
+ }
74
+ if (matchesKey(data, "shift+tab")) {
75
+ this.cycleFocus(-1);
76
+ return;
77
+ }
78
+ if (matchesKey(data, "a")) {
79
+ void this.approveSelected();
80
+ return;
81
+ }
82
+ if (matchesKey(data, "d")) {
83
+ void this.denySelected();
84
+ return;
85
+ }
86
+ if (matchesKey(data, "c")) {
87
+ void this.cancelRun();
88
+ return;
89
+ }
90
+ if (matchesKey(data, "l")) {
91
+ this.store.returnToLive();
92
+ return;
93
+ }
94
+ if (matchesKey(data, "w")) {
95
+ void this.rewindDisplayedFrame();
96
+ return;
97
+ }
98
+ if (matchesKey(data, "s")) {
99
+ this.focus = "scrubber";
100
+ return;
101
+ }
102
+ if (this.focus === "scrubber" && this.scrubber.handleInput(data)) {
103
+ return;
104
+ }
105
+ if (this.focus === "inspector") {
106
+ const result = this.inspector.handleInput(data);
107
+ if (result === "handled") {
108
+ return;
109
+ }
110
+ }
111
+ const treeResult = this.tree.handleInput(data);
112
+ if (treeResult === "focusInspector") {
113
+ this.focus = "inspector";
114
+ }
115
+ }
116
+
117
+ render(width: number, height = 34, theme: Theme) {
118
+ if (this.cachedLines && this.cachedWidth === width) {
119
+ return this.cachedLines;
120
+ }
121
+ const W = Math.max(60, width);
122
+ const H = Math.max(18, height);
123
+ const lines: string[] = [];
124
+ lines.push(...this.header.render(W, theme));
125
+ if (this.store.isStaleBannerVisible) {
126
+ const since = this.store.staleSince ? Math.max(0, Math.floor((Date.now() - this.store.staleSince.getTime()) / 1_000)) : 0;
127
+ lines.push(truncateToWidth(paint(theme, "warning", ` stale: gateway disconnected for ${since}s; showing last known tree`), W));
128
+ }
129
+ lines.push(...this.scrubber.render(W, theme));
130
+
131
+ const bodyHeight = Math.max(8, H - lines.length - 2);
132
+ const leftWidth = Math.max(30, Math.min(Math.floor(W * 0.46), W - 31));
133
+ const rightWidth = Math.max(24, W - leftWidth - 1);
134
+ const left = this.tree.render(leftWidth, bodyHeight, theme);
135
+ const right = this.inspector.render(rightWidth, bodyHeight, theme);
136
+ for (let index = 0; index < bodyHeight; index += 1) {
137
+ lines.push(joinPanes(left[index] ?? "", right[index] ?? "", leftWidth, rightWidth));
138
+ }
139
+
140
+ const focusLabel = paint(theme, "accent", this.focus);
141
+ lines.push(
142
+ truncateToWidth(
143
+ paint(
144
+ theme,
145
+ "dim",
146
+ ` focus:${stripAnsi(focusLabel)} tab:focus arrows/jk:tree 1-3:tabs s:frames a/d:approve/deny w:rewind c:cancel q:close`,
147
+ ),
148
+ W,
149
+ ),
150
+ );
151
+ this.cachedWidth = width;
152
+ this.cachedLines = lines;
153
+ return lines;
154
+ }
155
+
156
+ invalidate() {
157
+ this.cachedLines = undefined;
158
+ this.cachedWidth = 0;
159
+ }
160
+
161
+ dispose() {
162
+ this.store.disconnect();
163
+ }
164
+
165
+ private cycleFocus(delta: number) {
166
+ const panes: FocusPane[] = ["tree", "inspector", "scrubber"];
167
+ const current = panes.indexOf(this.focus);
168
+ this.focus = panes[(current + delta + panes.length) % panes.length];
169
+ }
170
+
171
+ private selectedTask() {
172
+ const node = this.store.selectedNode;
173
+ const runId = this.store.runId;
174
+ const nodeId = node?.task?.nodeId;
175
+ if (!runId || !nodeId) {
176
+ return undefined;
177
+ }
178
+ return {
179
+ runId,
180
+ nodeId,
181
+ iteration: node.task?.iteration ?? 0,
182
+ };
183
+ }
184
+
185
+ private async approveSelected() {
186
+ const task = this.selectedTask();
187
+ if (!task) {
188
+ this.onNotify("No task node selected.", "warning");
189
+ return;
190
+ }
191
+ try {
192
+ await this.client.approve(task.runId, task.nodeId, task.iteration);
193
+ this.onNotify(`Approved ${task.nodeId}.`, "info");
194
+ } catch (error) {
195
+ this.onNotify(error instanceof Error ? error.message : String(error), "error");
196
+ }
197
+ }
198
+
199
+ private async denySelected() {
200
+ const task = this.selectedTask();
201
+ if (!task) {
202
+ this.onNotify("No task node selected.", "warning");
203
+ return;
204
+ }
205
+ try {
206
+ await this.client.deny(task.runId, task.nodeId, task.iteration);
207
+ this.onNotify(`Denied ${task.nodeId}.`, "warning");
208
+ } catch (error) {
209
+ this.onNotify(error instanceof Error ? error.message : String(error), "error");
210
+ }
211
+ }
212
+
213
+ private async cancelRun() {
214
+ if (!this.store.runId) {
215
+ return;
216
+ }
217
+ try {
218
+ await this.client.cancel(this.store.runId);
219
+ this.onNotify(`Cancelling ${this.store.runId.slice(0, 8)}.`, "warning");
220
+ } catch (error) {
221
+ this.onNotify(error instanceof Error ? error.message : String(error), "error");
222
+ }
223
+ }
224
+
225
+ private async rewindDisplayedFrame() {
226
+ if (!this.store.isRewindEligible) {
227
+ this.onNotify("Rewind is only available while viewing a historical frame for a live run.", "warning");
228
+ return;
229
+ }
230
+ await this.store.rewind(this.store.displayedFrameNo, true);
231
+ }
232
+ }