gsd-pi 2.18.0 → 2.19.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/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/dist/resources/extensions/gsd/auto.ts +276 -19
- package/dist/resources/extensions/gsd/captures.ts +384 -0
- package/dist/resources/extensions/gsd/commands.ts +139 -3
- package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/dist/resources/extensions/gsd/metrics.ts +48 -0
- package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/dist/resources/extensions/gsd/model-router.ts +256 -0
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/dist/resources/extensions/gsd/preferences.ts +73 -0
- package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/dist/resources/extensions/remote-questions/format.ts +12 -6
- package/dist/resources/extensions/remote-questions/manager.ts +8 -0
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/src/resources/extensions/gsd/auto.ts +276 -19
- package/src/resources/extensions/gsd/captures.ts +384 -0
- package/src/resources/extensions/gsd/commands.ts +139 -3
- package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/src/resources/extensions/gsd/metrics.ts +48 -0
- package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/src/resources/extensions/gsd/model-router.ts +256 -0
- package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/src/resources/extensions/gsd/preferences.ts +73 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/src/resources/extensions/gsd/triage-ui.ts +175 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/src/resources/extensions/remote-questions/format.ts +12 -6
- package/src/resources/extensions/remote-questions/manager.ts +8 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { Theme } from "@gsd/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
|
|
3
|
+
import { loadVisualizerData, type VisualizerData } from "./visualizer-data.js";
|
|
4
|
+
import {
|
|
5
|
+
renderProgressView,
|
|
6
|
+
renderDepsView,
|
|
7
|
+
renderMetricsView,
|
|
8
|
+
renderTimelineView,
|
|
9
|
+
} from "./visualizer-views.js";
|
|
10
|
+
|
|
11
|
+
const TAB_LABELS = ["1 Progress", "2 Deps", "3 Metrics", "4 Timeline"];
|
|
12
|
+
|
|
13
|
+
export class GSDVisualizerOverlay {
|
|
14
|
+
private tui: { requestRender: () => void };
|
|
15
|
+
private theme: Theme;
|
|
16
|
+
private onClose: () => void;
|
|
17
|
+
|
|
18
|
+
activeTab = 0;
|
|
19
|
+
scrollOffsets: number[] = [0, 0, 0, 0];
|
|
20
|
+
loading = true;
|
|
21
|
+
disposed = false;
|
|
22
|
+
cachedWidth?: number;
|
|
23
|
+
cachedLines?: string[];
|
|
24
|
+
refreshTimer: ReturnType<typeof setInterval>;
|
|
25
|
+
data: VisualizerData | null = null;
|
|
26
|
+
basePath: string;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
tui: { requestRender: () => void },
|
|
30
|
+
theme: Theme,
|
|
31
|
+
onClose: () => void,
|
|
32
|
+
) {
|
|
33
|
+
this.tui = tui;
|
|
34
|
+
this.theme = theme;
|
|
35
|
+
this.onClose = onClose;
|
|
36
|
+
this.basePath = process.cwd();
|
|
37
|
+
|
|
38
|
+
loadVisualizerData(this.basePath).then((d) => {
|
|
39
|
+
this.data = d;
|
|
40
|
+
this.loading = false;
|
|
41
|
+
this.tui.requestRender();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.refreshTimer = setInterval(() => {
|
|
45
|
+
loadVisualizerData(this.basePath).then((d) => {
|
|
46
|
+
if (this.disposed) return;
|
|
47
|
+
this.data = d;
|
|
48
|
+
this.invalidate();
|
|
49
|
+
this.tui.requestRender();
|
|
50
|
+
});
|
|
51
|
+
}, 2000);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
handleInput(data: string): void {
|
|
55
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
56
|
+
this.dispose();
|
|
57
|
+
this.onClose();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (matchesKey(data, Key.tab)) {
|
|
62
|
+
this.activeTab = (this.activeTab + 1) % 4;
|
|
63
|
+
this.invalidate();
|
|
64
|
+
this.tui.requestRender();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (data === "1" || data === "2" || data === "3" || data === "4") {
|
|
69
|
+
this.activeTab = parseInt(data, 10) - 1;
|
|
70
|
+
this.invalidate();
|
|
71
|
+
this.tui.requestRender();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
|
76
|
+
this.scrollOffsets[this.activeTab]++;
|
|
77
|
+
this.invalidate();
|
|
78
|
+
this.tui.requestRender();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
|
83
|
+
this.scrollOffsets[this.activeTab] = Math.max(0, this.scrollOffsets[this.activeTab] - 1);
|
|
84
|
+
this.invalidate();
|
|
85
|
+
this.tui.requestRender();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (data === "g") {
|
|
90
|
+
this.scrollOffsets[this.activeTab] = 0;
|
|
91
|
+
this.invalidate();
|
|
92
|
+
this.tui.requestRender();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (data === "G") {
|
|
97
|
+
this.scrollOffsets[this.activeTab] = 999;
|
|
98
|
+
this.invalidate();
|
|
99
|
+
this.tui.requestRender();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
render(width: number): string[] {
|
|
105
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
106
|
+
return this.cachedLines;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const th = this.theme;
|
|
110
|
+
const innerWidth = width - 4;
|
|
111
|
+
const content: string[] = [];
|
|
112
|
+
|
|
113
|
+
// Tab bar
|
|
114
|
+
const tabs = TAB_LABELS.map((label, i) => {
|
|
115
|
+
if (i === this.activeTab) {
|
|
116
|
+
return th.fg("accent", `[${label}]`);
|
|
117
|
+
}
|
|
118
|
+
return th.fg("dim", `[${label}]`);
|
|
119
|
+
});
|
|
120
|
+
content.push(" " + tabs.join(" "));
|
|
121
|
+
content.push("");
|
|
122
|
+
|
|
123
|
+
if (this.loading) {
|
|
124
|
+
const loadingText = "Loading…";
|
|
125
|
+
const vis = visibleWidth(loadingText);
|
|
126
|
+
const leftPad = Math.max(0, Math.floor((innerWidth - vis) / 2));
|
|
127
|
+
content.push(" ".repeat(leftPad) + loadingText);
|
|
128
|
+
} else if (this.data) {
|
|
129
|
+
let viewLines: string[] = [];
|
|
130
|
+
switch (this.activeTab) {
|
|
131
|
+
case 0:
|
|
132
|
+
viewLines = renderProgressView(this.data, th, innerWidth);
|
|
133
|
+
break;
|
|
134
|
+
case 1:
|
|
135
|
+
viewLines = renderDepsView(this.data, th, innerWidth);
|
|
136
|
+
break;
|
|
137
|
+
case 2:
|
|
138
|
+
viewLines = renderMetricsView(this.data, th, innerWidth);
|
|
139
|
+
break;
|
|
140
|
+
case 3:
|
|
141
|
+
viewLines = renderTimelineView(this.data, th, innerWidth);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
content.push(...viewLines);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Apply scroll
|
|
148
|
+
const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24);
|
|
149
|
+
const chromeHeight = 2;
|
|
150
|
+
const visibleContentRows = Math.max(1, viewportHeight - chromeHeight);
|
|
151
|
+
const maxScroll = Math.max(0, content.length - visibleContentRows);
|
|
152
|
+
this.scrollOffsets[this.activeTab] = Math.min(this.scrollOffsets[this.activeTab], maxScroll);
|
|
153
|
+
const offset = this.scrollOffsets[this.activeTab];
|
|
154
|
+
const visibleContent = content.slice(offset, offset + visibleContentRows);
|
|
155
|
+
|
|
156
|
+
const lines = this.wrapInBox(visibleContent, width);
|
|
157
|
+
|
|
158
|
+
// Footer hint
|
|
159
|
+
const hint = th.fg("dim", "Tab/1-4 switch · ↑↓ scroll · g/G top/end · esc close");
|
|
160
|
+
const hintVis = visibleWidth(hint);
|
|
161
|
+
const hintPad = Math.max(0, Math.floor((width - hintVis) / 2));
|
|
162
|
+
lines.push(" ".repeat(hintPad) + hint);
|
|
163
|
+
|
|
164
|
+
this.cachedWidth = width;
|
|
165
|
+
this.cachedLines = lines;
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private wrapInBox(inner: string[], width: number): string[] {
|
|
170
|
+
const th = this.theme;
|
|
171
|
+
const border = (s: string) => th.fg("borderAccent", s);
|
|
172
|
+
const innerWidth = width - 4;
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
|
|
175
|
+
for (const line of inner) {
|
|
176
|
+
const truncated = truncateToWidth(line, innerWidth);
|
|
177
|
+
const padWidth = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
178
|
+
lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│"));
|
|
179
|
+
}
|
|
180
|
+
lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
|
|
181
|
+
return lines;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
invalidate(): void {
|
|
185
|
+
this.cachedWidth = undefined;
|
|
186
|
+
this.cachedLines = undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
dispose(): void {
|
|
190
|
+
this.disposed = true;
|
|
191
|
+
clearInterval(this.refreshTimer);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// View renderers for the GSD workflow visualizer overlay.
|
|
2
|
+
|
|
3
|
+
import type { Theme } from "@gsd/pi-coding-agent";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
5
|
+
import type { VisualizerData, VisualizerMilestone } from "./visualizer-data.js";
|
|
6
|
+
import { formatCost, formatTokenCount } from "./metrics.js";
|
|
7
|
+
|
|
8
|
+
// ─── Local Helpers ───────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function formatDuration(ms: number): string {
|
|
11
|
+
const s = Math.floor(ms / 1000);
|
|
12
|
+
if (s < 60) return `${s}s`;
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
const rs = s % 60;
|
|
15
|
+
if (m < 60) return `${m}m ${rs}s`;
|
|
16
|
+
const h = Math.floor(m / 60);
|
|
17
|
+
const rm = m % 60;
|
|
18
|
+
return `${h}h ${rm}m`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function padRight(content: string, width: number): string {
|
|
22
|
+
const vis = visibleWidth(content);
|
|
23
|
+
return content + " ".repeat(Math.max(0, width - vis));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function joinColumns(left: string, right: string, width: number): string {
|
|
27
|
+
const leftW = visibleWidth(left);
|
|
28
|
+
const rightW = visibleWidth(right);
|
|
29
|
+
if (leftW + rightW + 2 > width) {
|
|
30
|
+
return truncateToWidth(`${left} ${right}`, width);
|
|
31
|
+
}
|
|
32
|
+
return left + " ".repeat(width - leftW - rightW) + right;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Progress View ───────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export function renderProgressView(
|
|
38
|
+
data: VisualizerData,
|
|
39
|
+
th: Theme,
|
|
40
|
+
width: number,
|
|
41
|
+
): string[] {
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const ms of data.milestones) {
|
|
45
|
+
// Milestone header line
|
|
46
|
+
const statusGlyph =
|
|
47
|
+
ms.status === "complete"
|
|
48
|
+
? th.fg("success", "✓")
|
|
49
|
+
: ms.status === "active"
|
|
50
|
+
? th.fg("accent", "▸")
|
|
51
|
+
: th.fg("dim", "○");
|
|
52
|
+
const statusLabel =
|
|
53
|
+
ms.status === "complete"
|
|
54
|
+
? th.fg("success", "complete")
|
|
55
|
+
: ms.status === "active"
|
|
56
|
+
? th.fg("accent", "active")
|
|
57
|
+
: th.fg("dim", "pending");
|
|
58
|
+
const msLeft = `${ms.id}: ${ms.title}`;
|
|
59
|
+
const msRight = `${statusGlyph} ${statusLabel}`;
|
|
60
|
+
lines.push(joinColumns(msLeft, msRight, width));
|
|
61
|
+
|
|
62
|
+
if (ms.slices.length === 0 && ms.dependsOn.length > 0) {
|
|
63
|
+
lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ms.status === "pending" && ms.dependsOn.length > 0) {
|
|
68
|
+
lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const sl of ms.slices) {
|
|
73
|
+
// Slice line
|
|
74
|
+
const slGlyph = sl.done
|
|
75
|
+
? th.fg("success", "✓")
|
|
76
|
+
: sl.active
|
|
77
|
+
? th.fg("accent", "▸")
|
|
78
|
+
: th.fg("dim", "○");
|
|
79
|
+
const riskColor =
|
|
80
|
+
sl.risk === "high"
|
|
81
|
+
? "warning"
|
|
82
|
+
: sl.risk === "medium"
|
|
83
|
+
? "text"
|
|
84
|
+
: "dim";
|
|
85
|
+
const riskBadge = th.fg(riskColor, sl.risk);
|
|
86
|
+
const slLeft = ` ${slGlyph} ${sl.id}: ${sl.title}`;
|
|
87
|
+
lines.push(joinColumns(slLeft, riskBadge, width));
|
|
88
|
+
|
|
89
|
+
// Show tasks for active slice
|
|
90
|
+
if (sl.active && sl.tasks.length > 0) {
|
|
91
|
+
for (const task of sl.tasks) {
|
|
92
|
+
const tGlyph = task.done
|
|
93
|
+
? th.fg("success", "✓")
|
|
94
|
+
: task.active
|
|
95
|
+
? th.fg("accent", "▸")
|
|
96
|
+
: th.fg("dim", "○");
|
|
97
|
+
lines.push(` ${tGlyph} ${task.id}: ${task.title}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Dependencies View ───────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export function renderDepsView(
|
|
109
|
+
data: VisualizerData,
|
|
110
|
+
th: Theme,
|
|
111
|
+
width: number,
|
|
112
|
+
): string[] {
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
|
|
115
|
+
// Milestone Dependencies
|
|
116
|
+
lines.push(th.fg("accent", th.bold("Milestone Dependencies")));
|
|
117
|
+
lines.push("");
|
|
118
|
+
|
|
119
|
+
const msDeps = data.milestones.filter((ms) => ms.dependsOn.length > 0);
|
|
120
|
+
if (msDeps.length === 0) {
|
|
121
|
+
lines.push(th.fg("dim", " No milestone dependencies."));
|
|
122
|
+
} else {
|
|
123
|
+
for (const ms of msDeps) {
|
|
124
|
+
for (const dep of ms.dependsOn) {
|
|
125
|
+
lines.push(
|
|
126
|
+
` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", ms.id)}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
lines.push("");
|
|
133
|
+
|
|
134
|
+
// Slice Dependencies (active milestone)
|
|
135
|
+
lines.push(th.fg("accent", th.bold("Slice Dependencies (active milestone)")));
|
|
136
|
+
lines.push("");
|
|
137
|
+
|
|
138
|
+
const activeMs = data.milestones.find((ms) => ms.status === "active");
|
|
139
|
+
if (!activeMs) {
|
|
140
|
+
lines.push(th.fg("dim", " No active milestone."));
|
|
141
|
+
} else {
|
|
142
|
+
const slDeps = activeMs.slices.filter((sl) => sl.depends.length > 0);
|
|
143
|
+
if (slDeps.length === 0) {
|
|
144
|
+
lines.push(th.fg("dim", " No slice dependencies."));
|
|
145
|
+
} else {
|
|
146
|
+
for (const sl of slDeps) {
|
|
147
|
+
for (const dep of sl.depends) {
|
|
148
|
+
lines.push(
|
|
149
|
+
` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", sl.id)}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Metrics View ────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export function renderMetricsView(
|
|
162
|
+
data: VisualizerData,
|
|
163
|
+
th: Theme,
|
|
164
|
+
width: number,
|
|
165
|
+
): string[] {
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
|
|
168
|
+
if (data.totals === null) {
|
|
169
|
+
lines.push(th.fg("dim", "No metrics data available."));
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const totals = data.totals;
|
|
174
|
+
|
|
175
|
+
// Summary line
|
|
176
|
+
lines.push(
|
|
177
|
+
th.fg("accent", th.bold("Summary")),
|
|
178
|
+
);
|
|
179
|
+
lines.push(
|
|
180
|
+
` Cost: ${th.fg("text", formatCost(totals.cost))} ` +
|
|
181
|
+
`Tokens: ${th.fg("text", formatTokenCount(totals.tokens.total))} ` +
|
|
182
|
+
`Units: ${th.fg("text", String(totals.units))}`,
|
|
183
|
+
);
|
|
184
|
+
lines.push("");
|
|
185
|
+
|
|
186
|
+
const barWidth = Math.max(10, width - 40);
|
|
187
|
+
|
|
188
|
+
// By Phase
|
|
189
|
+
if (data.byPhase.length > 0) {
|
|
190
|
+
lines.push(th.fg("accent", th.bold("By Phase")));
|
|
191
|
+
lines.push("");
|
|
192
|
+
|
|
193
|
+
const maxPhaseCost = Math.max(...data.byPhase.map((p) => p.cost));
|
|
194
|
+
|
|
195
|
+
for (const phase of data.byPhase) {
|
|
196
|
+
const pct = totals.cost > 0 ? (phase.cost / totals.cost) * 100 : 0;
|
|
197
|
+
const fillLen =
|
|
198
|
+
maxPhaseCost > 0
|
|
199
|
+
? Math.round((phase.cost / maxPhaseCost) * barWidth)
|
|
200
|
+
: 0;
|
|
201
|
+
const bar =
|
|
202
|
+
th.fg("accent", "█".repeat(fillLen)) +
|
|
203
|
+
th.fg("dim", "░".repeat(barWidth - fillLen));
|
|
204
|
+
const label = padRight(phase.phase, 14);
|
|
205
|
+
const costStr = formatCost(phase.cost);
|
|
206
|
+
const pctStr = `${pct.toFixed(1)}%`;
|
|
207
|
+
const tokenStr = formatTokenCount(phase.tokens.total);
|
|
208
|
+
lines.push(` ${label} ${bar} ${costStr} ${pctStr} ${tokenStr}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
lines.push("");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// By Model
|
|
215
|
+
if (data.byModel.length > 0) {
|
|
216
|
+
lines.push(th.fg("accent", th.bold("By Model")));
|
|
217
|
+
lines.push("");
|
|
218
|
+
|
|
219
|
+
const maxModelCost = Math.max(...data.byModel.map((m) => m.cost));
|
|
220
|
+
|
|
221
|
+
for (const model of data.byModel) {
|
|
222
|
+
const pct = totals.cost > 0 ? (model.cost / totals.cost) * 100 : 0;
|
|
223
|
+
const fillLen =
|
|
224
|
+
maxModelCost > 0
|
|
225
|
+
? Math.round((model.cost / maxModelCost) * barWidth)
|
|
226
|
+
: 0;
|
|
227
|
+
const bar =
|
|
228
|
+
th.fg("accent", "█".repeat(fillLen)) +
|
|
229
|
+
th.fg("dim", "░".repeat(barWidth - fillLen));
|
|
230
|
+
const label = padRight(model.model, 20);
|
|
231
|
+
const costStr = formatCost(model.cost);
|
|
232
|
+
const pctStr = `${pct.toFixed(1)}%`;
|
|
233
|
+
lines.push(` ${label} ${bar} ${costStr} ${pctStr}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return lines;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Timeline View ──────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export function renderTimelineView(
|
|
243
|
+
data: VisualizerData,
|
|
244
|
+
th: Theme,
|
|
245
|
+
width: number,
|
|
246
|
+
): string[] {
|
|
247
|
+
const lines: string[] = [];
|
|
248
|
+
|
|
249
|
+
if (data.units.length === 0) {
|
|
250
|
+
lines.push(th.fg("dim", "No execution history."));
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Show up to 20 most recent (units are sorted by startedAt asc, show most recent)
|
|
255
|
+
const recent = data.units.slice(-20).reverse();
|
|
256
|
+
|
|
257
|
+
const maxDuration = Math.max(
|
|
258
|
+
...recent.map((u) => u.finishedAt - u.startedAt),
|
|
259
|
+
);
|
|
260
|
+
const timeBarWidth = Math.max(4, Math.min(12, width - 60));
|
|
261
|
+
|
|
262
|
+
for (const unit of recent) {
|
|
263
|
+
const dt = new Date(unit.startedAt);
|
|
264
|
+
const hh = String(dt.getHours()).padStart(2, "0");
|
|
265
|
+
const mm = String(dt.getMinutes()).padStart(2, "0");
|
|
266
|
+
const time = `${hh}:${mm}`;
|
|
267
|
+
|
|
268
|
+
const duration = unit.finishedAt - unit.startedAt;
|
|
269
|
+
const glyph =
|
|
270
|
+
unit.finishedAt > 0
|
|
271
|
+
? th.fg("success", "✓")
|
|
272
|
+
: th.fg("accent", "▸");
|
|
273
|
+
|
|
274
|
+
const typeLabel = padRight(unit.type, 16);
|
|
275
|
+
const idLabel = padRight(unit.id, 14);
|
|
276
|
+
|
|
277
|
+
const fillLen =
|
|
278
|
+
maxDuration > 0
|
|
279
|
+
? Math.round((duration / maxDuration) * timeBarWidth)
|
|
280
|
+
: 0;
|
|
281
|
+
const bar =
|
|
282
|
+
th.fg("accent", "█".repeat(fillLen)) +
|
|
283
|
+
th.fg("dim", "░".repeat(timeBarWidth - fillLen));
|
|
284
|
+
|
|
285
|
+
const durStr = formatDuration(duration);
|
|
286
|
+
const costStr = formatCost(unit.cost);
|
|
287
|
+
|
|
288
|
+
const line = ` ${time} ${glyph} ${typeLabel} ${idLabel} ${bar} ${durStr} ${costStr}`;
|
|
289
|
+
lines.push(truncateToWidth(line, width));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return lines;
|
|
293
|
+
}
|
|
@@ -12,6 +12,7 @@ const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
|
|
|
12
12
|
export class DiscordAdapter implements ChannelAdapter {
|
|
13
13
|
readonly name = "discord" as const;
|
|
14
14
|
private botUserId: string | null = null;
|
|
15
|
+
private guildId: string | null = null;
|
|
15
16
|
private readonly token: string;
|
|
16
17
|
private readonly channelId: string;
|
|
17
18
|
|
|
@@ -24,6 +25,17 @@ export class DiscordAdapter implements ChannelAdapter {
|
|
|
24
25
|
const res = await this.discordApi("GET", "/users/@me");
|
|
25
26
|
if (!res.id) throw new Error("Discord auth failed: invalid token");
|
|
26
27
|
this.botUserId = String(res.id);
|
|
28
|
+
|
|
29
|
+
// Resolve guild ID for message URL generation.
|
|
30
|
+
// The channel belongs to a guild — fetch channel info to discover it.
|
|
31
|
+
try {
|
|
32
|
+
const channelInfo = await this.discordApi("GET", `/channels/${this.channelId}`);
|
|
33
|
+
if (channelInfo.guild_id) {
|
|
34
|
+
this.guildId = String(channelInfo.guild_id);
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Non-fatal — message URLs will be omitted if guild ID can't be resolved
|
|
38
|
+
}
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
|
|
@@ -46,12 +58,18 @@ export class DiscordAdapter implements ChannelAdapter {
|
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
// Build message URL if guild ID is available
|
|
62
|
+
const messageUrl = this.guildId
|
|
63
|
+
? `https://discord.com/channels/${this.guildId}/${this.channelId}/${messageId}`
|
|
64
|
+
: undefined;
|
|
65
|
+
|
|
49
66
|
return {
|
|
50
67
|
ref: {
|
|
51
68
|
id: prompt.id,
|
|
52
69
|
channel: "discord",
|
|
53
70
|
messageId,
|
|
54
71
|
channelId: this.channelId,
|
|
72
|
+
threadUrl: messageUrl,
|
|
55
73
|
},
|
|
56
74
|
};
|
|
57
75
|
}
|
|
@@ -67,6 +85,21 @@ export class DiscordAdapter implements ChannelAdapter {
|
|
|
67
85
|
return this.checkReplies(prompt, ref);
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Acknowledge that an answer was received by adding a ✅ reaction to the
|
|
90
|
+
* original prompt message. Best-effort — failures are silently ignored.
|
|
91
|
+
*/
|
|
92
|
+
async acknowledgeAnswer(ref: RemotePromptRef): Promise<void> {
|
|
93
|
+
try {
|
|
94
|
+
await this.discordApi(
|
|
95
|
+
"PUT",
|
|
96
|
+
`/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent("✅")}/@me`,
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
// Best-effort — don't let acknowledgement failures affect the flow
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
70
103
|
private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
|
|
71
104
|
const reactions: Array<{ emoji: string; count: number }> = [];
|
|
72
105
|
for (const emoji of NUMBER_EMOJIS) {
|
|
@@ -69,18 +69,24 @@ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]
|
|
|
69
69
|
return `${emoji} **${opt.label}** — ${opt.description}`;
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
const footerParts: string[] = [];
|
|
73
|
+
if (supportsReactions) {
|
|
74
|
+
footerParts.push(q.allowMultiple
|
|
75
|
+
? "Reply with comma-separated choices (`1,3`) or react with matching numbers"
|
|
76
|
+
: "Reply with a number or react with the matching number");
|
|
77
|
+
} else {
|
|
78
|
+
footerParts.push(`Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`);
|
|
79
|
+
}
|
|
80
|
+
if (prompt.context?.source) {
|
|
81
|
+
footerParts.push(`Source: ${prompt.context.source}`);
|
|
82
|
+
}
|
|
77
83
|
|
|
78
84
|
return {
|
|
79
85
|
title: q.header,
|
|
80
86
|
description: q.question,
|
|
81
87
|
color: 0x7c3aed,
|
|
82
88
|
fields: [{ name: "Options", value: optionLines.join("\n") }],
|
|
83
|
-
footer: { text:
|
|
89
|
+
footer: { text: footerParts.join(" · ") },
|
|
84
90
|
};
|
|
85
91
|
});
|
|
86
92
|
|
|
@@ -76,6 +76,14 @@ export async function tryRemoteQuestions(
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
markPromptAnswered(prompt.id, answer);
|
|
79
|
+
|
|
80
|
+
// Acknowledge receipt with a ✅ on Discord (Slack threads are self-evident)
|
|
81
|
+
if (config.channel === "discord" && dispatch.ref) {
|
|
82
|
+
try {
|
|
83
|
+
await (adapter as import("./discord-adapter.js").DiscordAdapter).acknowledgeAnswer(dispatch.ref);
|
|
84
|
+
} catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
return {
|
|
80
88
|
content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }],
|
|
81
89
|
details: {
|