@kata-sh/pi-symphony-extension 0.1.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/src/console.ts ADDED
@@ -0,0 +1,552 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { buildEscalationRows, buildIssueRows, buildWorkerRows, formatEventRows, type EscalationRow, type IssueRow, type WorkerRow } from "./console-model.ts";
4
+ import { startSymphonyEventStream, type EventStreamHandle } from "./event-stream.ts";
5
+ import type { SymphonyEventEnvelope, SymphonyStateResponse } from "./http-client.ts";
6
+ import type { SymphonyRuntime } from "./runtime.ts";
7
+ import type { ExtensionState } from "./state.ts";
8
+
9
+ type ConsoleThemeColor = "accent" | "border" | "borderAccent" | "success" | "error" | "warning" | "muted" | "dim" | "text";
10
+
11
+ type ConsoleThemeBg = "selectedBg";
12
+
13
+ interface ConsoleTheme {
14
+ fg(color: ConsoleThemeColor, text: string): string;
15
+ bg?(color: ConsoleThemeBg, text: string): string;
16
+ bold(text: string): string;
17
+ }
18
+
19
+ export type ConsoleShortcutAction = "selectPrevious" | "selectNext" | "refresh" | "steer" | "respondEscalation" | "toggleDetails" | "close";
20
+
21
+ let activeConsole: SymphonyConsoleComponent | undefined;
22
+
23
+ export async function handleActiveConsoleShortcut(action: ConsoleShortcutAction, ctx: Pick<ExtensionContext, "ui">): Promise<void> {
24
+ if (!activeConsole) {
25
+ ctx.ui.notify("No Symphony console is open. Use /symphony:console first.", "warning");
26
+ return;
27
+ }
28
+
29
+ switch (action) {
30
+ case "selectPrevious":
31
+ activeConsole.selectPreviousWorker();
32
+ return;
33
+ case "selectNext":
34
+ activeConsole.selectNextWorker();
35
+ return;
36
+ case "refresh":
37
+ await activeConsole.refreshNow();
38
+ return;
39
+ case "steer":
40
+ await activeConsole.steerNow();
41
+ return;
42
+ case "respondEscalation":
43
+ await activeConsole.respondToEscalationNow();
44
+ return;
45
+ case "toggleDetails":
46
+ activeConsole.toggleDetails();
47
+ return;
48
+ case "close":
49
+ activeConsole.closeConsole();
50
+ return;
51
+ }
52
+ }
53
+
54
+ export function closeActiveConsole(ctx: Pick<ExtensionContext, "ui">): void {
55
+ if (activeConsole) {
56
+ activeConsole.closeConsole();
57
+ return;
58
+ }
59
+ ctx.ui.setWidget("symphony-console", undefined);
60
+ }
61
+
62
+ export interface ConsoleOptions {
63
+ state: ExtensionState;
64
+ getState: () => SymphonyStateResponse | undefined;
65
+ getEvents: () => SymphonyEventEnvelope[];
66
+ refresh: () => Promise<void>;
67
+ steer: (issueIdentifier: string, instruction: string) => Promise<void>;
68
+ respondToEscalation: (requestId: string, response: unknown) => Promise<void>;
69
+ prompt: (title: string, label: string) => Promise<string | undefined>;
70
+ close: () => void;
71
+ requestRender: () => void;
72
+ notify: (message: string, level: "info" | "warning" | "error") => void;
73
+ theme?: ConsoleTheme;
74
+ }
75
+
76
+ export class SymphonyConsoleComponent {
77
+ private refreshing = false;
78
+ private selectedIndex = 0;
79
+
80
+ constructor(private readonly options: ConsoleOptions) {}
81
+
82
+ handleInput(data: string): void {
83
+ if (data === "q" || data === "Q" || matchesKey(data, "escape")) {
84
+ this.closeConsole();
85
+ return;
86
+ }
87
+
88
+ if (data === "r" || data === "R") {
89
+ void this.refreshNow();
90
+ return;
91
+ }
92
+
93
+ if (data === "d" || data === "D") {
94
+ this.toggleDetails();
95
+ return;
96
+ }
97
+
98
+ if (data === "s" || data === "S") {
99
+ void this.steerNow();
100
+ return;
101
+ }
102
+
103
+ if (data === "e" || data === "E") {
104
+ void this.respondToEscalationNow();
105
+ return;
106
+ }
107
+
108
+ if (data === "\u001b[A" || matchesKey(data, "up")) {
109
+ this.selectPreviousWorker();
110
+ return;
111
+ }
112
+
113
+ if (data === "\u001b[B" || matchesKey(data, "down")) {
114
+ this.selectNextWorker();
115
+ }
116
+ }
117
+
118
+ render(width: number): string[] {
119
+ const state = this.options.state;
120
+ const health = state.lastKnownState;
121
+ const symphonyState = this.options.getState();
122
+ const workers: WorkerRow[] = buildWorkerRows(symphonyState);
123
+ const issueRows: IssueRow[] = buildIssueRows(symphonyState);
124
+ const escalationRows: EscalationRow[] = buildEscalationRows(symphonyState);
125
+ this.clampSelection(issueRows.length + escalationRows.length);
126
+ const runningRows = issueRows.filter((row) => row.kind === "running");
127
+ const retryRows = issueRows.filter((row) => row.kind === "retry");
128
+ const blockedRows = issueRows.filter((row) => row.kind === "blocked");
129
+ const completedRows = issueRows.filter((row) => row.kind === "completed");
130
+ const selectedIssue = issueRows[this.selectedIndex];
131
+ const theme = this.options.theme;
132
+ const connection = state.attachedBaseUrl ? color(theme, "success", "attached") : color(theme, "error", "detached");
133
+ const polling = health?.pollingChecking ? color(theme, "warning", "checking") : color(theme, "success", "idle");
134
+ const consoleWidth = Math.max(44, width);
135
+ const lines = [
136
+ color(theme, "accent", bold(theme, "Symphony Console")),
137
+ "",
138
+ ...boxLines("Status", [
139
+ `connection: ${connection}`,
140
+ `dashboard: ${color(theme, "dim", state.attachedBaseUrl ?? "none")}`,
141
+ `project: ${color(theme, "dim", health?.trackerProjectUrl ?? "none")}`,
142
+ `polling: ${polling} | next poll: ${health?.nextPollInMs ?? 0}ms`,
143
+ `owned process: ${state.ownedProcess ? color(theme, "success", `pid ${state.ownedProcess.pid}`) : color(theme, "dim", "none")}`,
144
+ `updated: ${color(theme, "dim", health?.updatedAt ?? "never")}`,
145
+ ], consoleWidth, theme),
146
+ "",
147
+ ...boxLines("Worker Summary", [
148
+ `workers: ${color(theme, "success", `running: ${health?.runningCount ?? workers.length}`)} | ${color(theme, "warning", `retry: ${health?.retryCount ?? retryRows.length}`)} | ${color(theme, "error", `blocked: ${health?.blockedCount ?? blockedRows.length}`)} | ${color(theme, "accent", `completed: ${health?.completedCount ?? completedRows.length}`)}`,
149
+ ], consoleWidth, theme),
150
+ "",
151
+ ...boxLines("Running Workers", renderIssueTable(runningRows, this.selectedIndex, 0, theme), consoleWidth, theme),
152
+ ...boxLines("Retry Queue", renderIssueTable(retryRows, this.selectedIndex, runningRows.length, theme), consoleWidth, theme),
153
+ ...boxLines("Blocked Issues", renderIssueTable(blockedRows, this.selectedIndex, runningRows.length + retryRows.length, theme), consoleWidth, theme),
154
+ ...boxLines("Completed Issues", renderIssueTable(completedRows, this.selectedIndex, runningRows.length + retryRows.length + blockedRows.length, theme), consoleWidth, theme),
155
+ ...boxLines("Selected Issue", renderSelectedIssueDetails(selectedIssue, state.console.showDetails, theme), consoleWidth, theme),
156
+ ...boxLines("Pending Escalations", renderEscalationTable(escalationRows, this.selectedIndex - issueRows.length, theme), consoleWidth, theme),
157
+ ...boxLines("Selected Escalation", renderSelectedEscalationDetails(escalationRows[this.selectedIndex - issueRows.length], state.console.showDetails, theme), consoleWidth, theme),
158
+ ...boxLines("Events", renderRecentEvents(formatEventRows(this.options.getEvents()), theme), consoleWidth, theme),
159
+ "",
160
+ ...boxLines("Actions", renderActionLegend(this.refreshing, consoleWidth, theme), consoleWidth, theme),
161
+ ];
162
+
163
+ return lines.map((line) => truncateToWidth(line, width));
164
+ }
165
+
166
+ invalidate(): void {}
167
+
168
+ selectPreviousWorker(): void {
169
+ this.moveSelection(-1);
170
+ }
171
+
172
+ selectNextWorker(): void {
173
+ this.moveSelection(1);
174
+ }
175
+
176
+ toggleDetails(): void {
177
+ this.options.state.console.showDetails = !this.options.state.console.showDetails;
178
+ this.options.requestRender();
179
+ }
180
+
181
+ async refreshNow(): Promise<void> {
182
+ await this.refresh();
183
+ }
184
+
185
+ async steerNow(): Promise<void> {
186
+ await this.steerSelectedWorker();
187
+ }
188
+
189
+ async respondToEscalationNow(): Promise<void> {
190
+ await this.respondToSelectedEscalation();
191
+ }
192
+
193
+ closeConsole(): void {
194
+ this.options.close();
195
+ }
196
+
197
+ private moveSelection(delta: number): void {
198
+ const symphonyState = this.options.getState();
199
+ const rowCount = buildIssueRows(symphonyState).length + buildEscalationRows(symphonyState).length;
200
+ if (rowCount === 0) return;
201
+ this.selectedIndex = Math.max(0, Math.min(rowCount - 1, this.selectedIndex + delta));
202
+ this.options.requestRender();
203
+ }
204
+
205
+ private clampSelection(rowCount: number): void {
206
+ if (rowCount === 0) {
207
+ this.selectedIndex = 0;
208
+ return;
209
+ }
210
+ this.selectedIndex = Math.max(0, Math.min(rowCount - 1, this.selectedIndex));
211
+ }
212
+
213
+ private async steerSelectedWorker(): Promise<void> {
214
+ const symphonyState = this.options.getState();
215
+ const issueRows = buildIssueRows(symphonyState);
216
+ const escalationRows = buildEscalationRows(symphonyState);
217
+ this.clampSelection(issueRows.length + escalationRows.length);
218
+ const issue = issueRows[this.selectedIndex];
219
+ if (!issue || issue.kind !== "running") {
220
+ this.options.notify("Select a running worker before steering", "warning");
221
+ return;
222
+ }
223
+
224
+ try {
225
+ const instruction = (await this.options.prompt("Steer Symphony worker", `Instruction for ${issue.issueIdentifier}`))?.trim();
226
+ if (!instruction) return;
227
+
228
+ await this.options.steer(issue.issueIdentifier, instruction);
229
+ this.options.notify(`Steer delivered to ${issue.issueIdentifier}`, "info");
230
+ } catch (error) {
231
+ this.options.notify(error instanceof Error ? error.message : String(error), "error");
232
+ } finally {
233
+ this.options.requestRender();
234
+ }
235
+ }
236
+
237
+ private async respondToSelectedEscalation(): Promise<void> {
238
+ const symphonyState = this.options.getState();
239
+ const issueRows = buildIssueRows(symphonyState);
240
+ const escalationRows = buildEscalationRows(symphonyState);
241
+ this.clampSelection(issueRows.length + escalationRows.length);
242
+ const escalation = escalationRows[this.selectedIndex - issueRows.length];
243
+ if (!escalation) {
244
+ this.options.notify("Select an escalation before responding", "warning");
245
+ return;
246
+ }
247
+
248
+ try {
249
+ const value = (await this.options.prompt("Respond to Symphony escalation", `Response for ${escalation.requestId}`))?.trim();
250
+ if (!value) return;
251
+
252
+ await this.options.respondToEscalation(escalation.requestId, parseEscalationResponseInput(value));
253
+ this.options.notify(`Escalation response sent for ${escalation.requestId}`, "info");
254
+ } catch (error) {
255
+ this.options.notify(error instanceof Error ? error.message : String(error), "error");
256
+ } finally {
257
+ this.options.requestRender();
258
+ }
259
+ }
260
+
261
+ private async refresh(): Promise<void> {
262
+ if (this.refreshing) return;
263
+
264
+ this.refreshing = true;
265
+ this.options.requestRender();
266
+ try {
267
+ await this.options.refresh();
268
+ } catch (error) {
269
+ this.options.notify(error instanceof Error ? error.message : String(error), "error");
270
+ } finally {
271
+ this.refreshing = false;
272
+ this.options.requestRender();
273
+ }
274
+ }
275
+ }
276
+
277
+ function renderIssueTable(rows: IssueRow[], selectedIndex: number, selectedOffset: number, theme?: ConsoleTheme): string[] {
278
+ const lines = [color(theme, "dim", "sel issue kind status attempt host detail")];
279
+ if (rows.length === 0) return [...lines, color(theme, "dim", "- none")];
280
+
281
+ for (const [index, row] of rows.entries()) {
282
+ const rowIndex = selectedOffset + index;
283
+ const selected = rowIndex === selectedIndex ? ">" : " ";
284
+ const line = [
285
+ selected,
286
+ pad(row.issueIdentifier, 8),
287
+ pad(row.kind, 10),
288
+ pad(row.status, 18),
289
+ pad(row.attempt, 7),
290
+ pad(row.workerHost, 9),
291
+ issueDetail(row),
292
+ ].join(" ");
293
+ lines.push(rowIndex === selectedIndex ? selectedLine(theme, line) : line);
294
+ }
295
+ return lines;
296
+ }
297
+
298
+ function renderSelectedIssueDetails(issue: IssueRow | undefined, showDetails: boolean, theme?: ConsoleTheme): string[] {
299
+ if (!showDetails) return [];
300
+ if (!issue) return [color(theme, "dim", "none")];
301
+
302
+ const lines = [
303
+ `issue: ${color(theme, "accent", issue.issueIdentifier)} ${issue.title}`,
304
+ `kind: ${issue.kind}`,
305
+ `status: ${issue.status}`,
306
+ ];
307
+
308
+ if (issue.kind === "running") {
309
+ lines.push(
310
+ `tracker state: ${color(theme, "success", issue.trackerState)}`,
311
+ `attempt: ${issue.attempt}`,
312
+ `turns: ${issue.turnCount} / ${issue.maxTurns}`,
313
+ `last activity: ${color(theme, "dim", issue.lastActivity)}`,
314
+ `worker host: ${issue.workerHost}`,
315
+ `workspace: ${color(theme, "dim", issue.workspacePath)}`,
316
+ `error: ${issue.errorPreview === "-" ? color(theme, "dim", issue.errorPreview) : color(theme, "error", issue.errorPreview)}`,
317
+ );
318
+ } else if (issue.kind === "retry") {
319
+ lines.push(
320
+ `attempt: ${issue.attempt}`,
321
+ `worker host: ${issue.workerHost}`,
322
+ `workspace: ${color(theme, "dim", issue.workspacePath)}`,
323
+ `error: ${issue.errorPreview === "-" ? color(theme, "dim", issue.errorPreview) : color(theme, "error", issue.errorPreview)}`,
324
+ );
325
+ } else if (issue.kind === "blocked") {
326
+ lines.push(`tracker state: ${color(theme, "warning", issue.trackerState)}`, `blockers: ${issue.blockers}`);
327
+ } else {
328
+ lines.push(`completed at: ${issue.completedAt}`);
329
+ }
330
+
331
+ return lines;
332
+ }
333
+
334
+ function renderEscalationTable(rows: EscalationRow[], selectedIndex: number, theme?: ConsoleTheme): string[] {
335
+ const lines = [color(theme, "dim", "sel request issue method timeout preview")];
336
+ if (rows.length === 0) return [...lines, color(theme, "dim", "- no pending escalations")];
337
+
338
+ for (const [index, row] of rows.entries()) {
339
+ const selected = index === selectedIndex ? ">" : " ";
340
+ const line = [selected, pad(row.requestId, 8), pad(row.issueIdentifier, 8), pad(row.method, 10), pad(row.timeout, 8), row.preview].join(" ");
341
+ lines.push(index === selectedIndex ? selectedLine(theme, line) : line);
342
+ }
343
+ return lines;
344
+ }
345
+
346
+ function renderSelectedEscalationDetails(escalation: EscalationRow | undefined, showDetails: boolean, theme?: ConsoleTheme): string[] {
347
+ if (!showDetails) return [];
348
+ if (!escalation) return [color(theme, "dim", "none")];
349
+
350
+ return [
351
+ `request: ${color(theme, "accent", escalation.requestId)}`,
352
+ `issue: ${escalation.issueIdentifier}`,
353
+ `method: ${escalation.method}`,
354
+ `created: ${color(theme, "dim", escalation.createdAt)}`,
355
+ `timeout: ${escalation.timeout}`,
356
+ `preview: ${escalation.preview}`,
357
+ ];
358
+ }
359
+
360
+ function parseEscalationResponseInput(value: string): unknown {
361
+ const trimmedValue = value.trimStart();
362
+ try {
363
+ return JSON.parse(trimmedValue);
364
+ } catch {
365
+ const firstChar = trimmedValue[0];
366
+ if (firstChar === "{" || firstChar === "[" || firstChar === "\"") {
367
+ throw new Error("Escalation response must be valid JSON or plain text");
368
+ }
369
+ return value;
370
+ }
371
+ }
372
+
373
+ function issueDetail(issue: IssueRow): string {
374
+ if (issue.kind === "blocked") return `blockers: ${issue.blockers}`;
375
+ if (issue.kind === "completed") return issue.completedAt;
376
+ if (issue.kind === "retry") return issue.errorPreview;
377
+ return issue.errorPreview === "-" ? issue.lastActivity : issue.errorPreview;
378
+ }
379
+
380
+ function renderRecentEvents(events: string[], theme?: ConsoleTheme): string[] {
381
+ if (events.length === 0) return [color(theme, "dim", "none")];
382
+ return events.map((event) => colorEventRow(event, theme));
383
+ }
384
+
385
+ function renderActionLegend(refreshing: boolean, width: number, theme?: ConsoleTheme): string[] {
386
+ const keyboard = "Keyboard: ctrl+shift+↑/↓ select • ctrl+shift+r refresh • ctrl+shift+t steer • ctrl+shift+e escalation • ctrl+shift+i details • ctrl+shift+q close";
387
+ const commands = "Commands: /symphony:refresh | /symphony:status | /symphony:stop";
388
+ if (refreshing) return [color(theme, "warning", "refreshing..."), commands];
389
+ if (visibleLength(keyboard) <= width - 4) return [keyboard, commands];
390
+ return [
391
+ "Keyboard: ctrl+shift+↑/↓ select • ctrl+shift+r refresh • ctrl+shift+t steer",
392
+ " ctrl+shift+e escalation • ctrl+shift+i details • ctrl+shift+q close",
393
+ commands,
394
+ ];
395
+ }
396
+
397
+ function colorEventRow(event: string, theme?: ConsoleTheme): string {
398
+ if (event.includes(" error ")) return color(theme, "error", event);
399
+ if (event.includes(" warn ") || event.includes(" warning ")) return color(theme, "warning", event);
400
+ if (event.includes(" debug ")) return color(theme, "dim", event);
401
+ if (event.includes(" info ")) return color(theme, "success", event);
402
+ return event;
403
+ }
404
+
405
+ function boxLines(title: string, content: string[], width: number, theme?: ConsoleTheme): string[] {
406
+ const innerWidth = Math.max(20, width - 4);
407
+ const titleText = ` ${title} `;
408
+ const border = (value: string) => color(theme, "borderAccent", value);
409
+ const top = `${border("┌")}${border("─".repeat(1))}${bold(theme, titleText)}${border("─".repeat(Math.max(1, innerWidth + 1 - visibleLength(titleText))))}${border("┐")}`;
410
+ const body = content.length === 0 ? [color(theme, "dim", "none")] : content;
411
+ return [
412
+ top,
413
+ ...body.map((line) => `${border("│")} ${padVisible(line, innerWidth)} ${border("│")}`),
414
+ `${border("└")}${border("─".repeat(innerWidth + 2))}${border("┘")}`,
415
+ ];
416
+ }
417
+
418
+ function color(theme: ConsoleTheme | undefined, name: ConsoleThemeColor, value: string): string {
419
+ return isConsoleTheme(theme) ? theme.fg(name, value) : value;
420
+ }
421
+
422
+ function bold(theme: ConsoleTheme | undefined, value: string): string {
423
+ return isConsoleTheme(theme) ? theme.bold(value) : value;
424
+ }
425
+
426
+ function selectedLine(theme: ConsoleTheme | undefined, value: string): string {
427
+ const styled = color(theme, "accent", bold(theme, value));
428
+ return isConsoleTheme(theme) && theme.bg ? theme.bg("selectedBg", styled) : styled;
429
+ }
430
+
431
+ function isConsoleTheme(theme: ConsoleTheme | undefined): theme is ConsoleTheme {
432
+ if (!theme) return false;
433
+ return typeof theme.fg === "function" && typeof theme.bold === "function";
434
+ }
435
+
436
+ function pad(value: string, width: number): string {
437
+ if (value.length >= width) return value.slice(0, width);
438
+ return value.padEnd(width, " ");
439
+ }
440
+
441
+ function padVisible(value: string, width: number): string {
442
+ const visible = visibleLength(value);
443
+ if (visible >= width) return value;
444
+ return `${value}${" ".repeat(width - visible)}`;
445
+ }
446
+
447
+ function visibleLength(value: string): number {
448
+ return stripAnsi(value).length;
449
+ }
450
+
451
+ function stripAnsi(value: string): string {
452
+ return value.replace(/\u001b\[[0-9;]*m/g, "");
453
+ }
454
+
455
+ export async function openConsole(ctx: ExtensionContext, runtime: SymphonyRuntime): Promise<void> {
456
+ if (!runtime.client) {
457
+ ctx.ui.notify("No Symphony server is attached. Use /symphony:start or /symphony:attach first.", "warning");
458
+ return;
459
+ }
460
+
461
+ try {
462
+ await runtime.refreshState();
463
+ } catch (error) {
464
+ ctx.ui.notify(runtime.errorText(error), "error");
465
+ }
466
+
467
+ ctx.ui.setWidget("symphony-console", (tui, theme) => {
468
+ let eventStream: EventStreamHandle | undefined;
469
+ let lastEventStreamErrorMessage: string | undefined;
470
+ let closed = false;
471
+ let liveRefreshTimer: ReturnType<typeof setTimeout> | undefined;
472
+ let liveRefreshInFlight = false;
473
+ let liveRefreshPending = false;
474
+
475
+ const closeWidget = () => {
476
+ closed = true;
477
+ if (liveRefreshTimer) clearTimeout(liveRefreshTimer);
478
+ eventStream?.close();
479
+ if (activeConsole === component) activeConsole = undefined;
480
+ };
481
+
482
+ const scheduleLiveRefresh = () => {
483
+ liveRefreshPending = true;
484
+ if (liveRefreshTimer) return;
485
+ liveRefreshTimer = setTimeout(() => {
486
+ liveRefreshTimer = undefined;
487
+ if (!liveRefreshPending) return;
488
+ liveRefreshPending = false;
489
+ void runLiveRefresh();
490
+ }, 100);
491
+ };
492
+
493
+ const runLiveRefresh = async () => {
494
+ if (closed) return;
495
+ if (liveRefreshInFlight) {
496
+ liveRefreshPending = true;
497
+ return;
498
+ }
499
+ liveRefreshInFlight = true;
500
+ try {
501
+ await runtime.refreshState();
502
+ } catch (error) {
503
+ ctx.ui.notify(`Symphony state refresh failed: ${runtime.errorText(error)}`, "warning");
504
+ } finally {
505
+ liveRefreshInFlight = false;
506
+ tui.requestRender();
507
+ if (liveRefreshPending && !closed) scheduleLiveRefresh();
508
+ }
509
+ };
510
+
511
+ if (runtime.state.attachedBaseUrl) {
512
+ eventStream = startSymphonyEventStream({
513
+ baseUrl: runtime.state.attachedBaseUrl,
514
+ onEvent: (event) => {
515
+ runtime.recordEvent(event);
516
+ tui.requestRender();
517
+ scheduleLiveRefresh();
518
+ },
519
+ onError: (error) => {
520
+ if (lastEventStreamErrorMessage === error.message) return;
521
+ lastEventStreamErrorMessage = error.message;
522
+ ctx.ui.notify(`Symphony event stream unavailable: ${error.message}`, "warning");
523
+ },
524
+ });
525
+ }
526
+
527
+ const component = new SymphonyConsoleComponent({
528
+ state: runtime.state,
529
+ getState: () => runtime.lastState,
530
+ getEvents: () => runtime.recentEvents,
531
+ refresh: async () => {
532
+ await runtime.requestRefresh();
533
+ },
534
+ steer: async (issueIdentifier, instruction) => {
535
+ await runtime.steerWorker(issueIdentifier, instruction);
536
+ },
537
+ respondToEscalation: async (requestId, response) => {
538
+ await runtime.respondToEscalation(requestId, response);
539
+ },
540
+ prompt: async (title, label) => ctx.ui.input(title, label),
541
+ close: () => {
542
+ closeWidget();
543
+ ctx.ui.setWidget("symphony-console", undefined);
544
+ },
545
+ requestRender: () => tui.requestRender(),
546
+ notify: (message, level) => ctx.ui.notify(message, level),
547
+ theme,
548
+ });
549
+ activeConsole = component;
550
+ return Object.assign(component, { dispose: closeWidget });
551
+ });
552
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,33 @@
1
+ export type SymphonyExtensionErrorKind =
2
+ | "missing_binary"
3
+ | "missing_workflow"
4
+ | "invalid_binary"
5
+ | "command_failed"
6
+ | "start_timeout"
7
+ | "attach_unreachable"
8
+ | "non_symphony_response"
9
+ | "invalid_json"
10
+ | "api_error"
11
+ | "no_attachment"
12
+ | "not_owned";
13
+
14
+ export class SymphonyExtensionError extends Error {
15
+ readonly kind: SymphonyExtensionErrorKind;
16
+ readonly details: Record<string, unknown>;
17
+
18
+ constructor(kind: SymphonyExtensionErrorKind, message: string, details: Record<string, unknown> = {}) {
19
+ super(message);
20
+ this.name = "SymphonyExtensionError";
21
+ this.kind = kind;
22
+ this.details = details;
23
+ }
24
+ }
25
+
26
+ export function formatError(error: unknown): string {
27
+ if (error instanceof SymphonyExtensionError) {
28
+ const detailText = Object.keys(error.details).length > 0 ? `\n${JSON.stringify(error.details, null, 2)}` : "";
29
+ return `${error.message}${detailText}`;
30
+ }
31
+ if (error instanceof Error) return error.message;
32
+ return String(error);
33
+ }