@wichayutdew/pi-workflows 0.2.1 → 0.2.3

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.
@@ -1,26 +1,34 @@
1
1
  import type {
2
- ExtensionCommandContext,
2
+ ExtensionContext,
3
3
  Theme,
4
4
  ThemeColor,
5
5
  } from '@earendil-works/pi-coding-agent';
6
6
  import {
7
+ Key,
7
8
  matchesKey,
8
9
  truncateToWidth,
9
10
  visibleWidth,
10
11
  wrapTextWithAnsi,
11
12
  type Component,
13
+ type KeyId,
12
14
  type TUI,
13
15
  } from '@earendil-works/pi-tui';
14
- import type { LoadedWorkflow } from './config/types.ts';
16
+ import {
17
+ DEFAULT_STATUS_SHORTCUT,
18
+ type LoadedWorkflow,
19
+ } from './config/types.ts';
15
20
  import type {
16
21
  StepHistoryEntry,
17
22
  WorkflowRun,
18
23
  WorkflowRunStatus,
19
24
  } from './engine/state.ts';
20
25
 
21
- const REFRESH_INTERVAL_MS = 1_000;
26
+ const REFRESH_INTERVAL_MS = 250;
27
+ const WORKING_ICON_FRAME_MS = 250;
28
+ const WORKING_ICON_FRAMES = ['◐', '◓', '◑', '◒'] as const;
22
29
  const WIDE_LAYOUT_MIN_COLUMNS = 92;
23
30
  const MAX_PATH_ROWS = 16;
31
+ const MAX_REASON_ROWS = 5;
24
32
 
25
33
  export type WorkflowStatusExecution =
26
34
  | {
@@ -41,7 +49,10 @@ export interface WorkflowStatusSnapshot {
41
49
  }
42
50
 
43
51
  type SnapshotProvider = () => WorkflowStatusSnapshot | undefined;
44
- type StepDisplayStatus = WorkflowRunStatus | 'completed';
52
+ type StepDisplayStatus = WorkflowRunStatus | 'completed' | 'failed';
53
+ type StatusViewTui = Pick<TUI, 'requestRender'> & {
54
+ terminal?: { rows: number };
55
+ };
45
56
 
46
57
  interface PathEntry {
47
58
  stepId: string;
@@ -78,27 +89,110 @@ export function formatWorkflowStatusText(
78
89
  return lines.join('\n');
79
90
  }
80
91
 
92
+ /** Format the full workflow status board. */
93
+ export function formatWorkflowStatusBoard(
94
+ snapshot: WorkflowStatusSnapshot,
95
+ width = 88,
96
+ theme: Theme = unstyledTheme,
97
+ ): string[] {
98
+ return renderBoard(
99
+ theme,
100
+ snapshot,
101
+ Math.max(8, Math.floor(width)),
102
+ false,
103
+ formatShortcutLabel(DEFAULT_STATUS_SHORTCUT),
104
+ );
105
+ }
106
+
107
+ const unstyledTheme = {
108
+ fg: (_color: string, value: string) => value,
109
+ bg: (_color: string, value: string) => value,
110
+ bold: (value: string) => value,
111
+ } as unknown as Theme;
112
+
113
+ const SHORTCUT_LABELS: Readonly<Record<string, string>> = {
114
+ ctrl: 'Ctrl',
115
+ shift: 'Shift',
116
+ alt: 'Alt',
117
+ super: 'Super',
118
+ escape: 'Esc',
119
+ esc: 'Esc',
120
+ enter: 'Enter',
121
+ return: 'Enter',
122
+ tab: 'Tab',
123
+ space: 'Space',
124
+ backspace: 'Backspace',
125
+ delete: 'Del',
126
+ insert: 'Ins',
127
+ clear: 'Clear',
128
+ home: 'Home',
129
+ end: 'End',
130
+ pageUp: 'PgUp',
131
+ pageDown: 'PgDn',
132
+ up: 'Up',
133
+ down: 'Down',
134
+ left: 'Left',
135
+ right: 'Right',
136
+ };
137
+
138
+ export function formatShortcutLabel(shortcut: KeyId): string {
139
+ return shortcut
140
+ .split('+')
141
+ .map((part) => {
142
+ const label = SHORTCUT_LABELS[part];
143
+ if (label) return label;
144
+ if (/^f\d+$/.test(part)) return part.toUpperCase();
145
+ return part.length === 1 ? part.toUpperCase() : part;
146
+ })
147
+ .join('+');
148
+ }
149
+
81
150
  export async function showWorkflowStatus(
82
- ctx: ExtensionCommandContext,
151
+ ctx: ExtensionContext,
83
152
  getSnapshot: SnapshotProvider,
153
+ statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
84
154
  ): Promise<void> {
85
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
86
- const view = new WorkflowStatusView(getSnapshot, tui, theme, done);
87
- view.start();
88
- return view;
89
- });
155
+ await ctx.ui.custom<void>(
156
+ (tui, theme, _keybindings, done) => {
157
+ const view = new WorkflowStatusView(
158
+ getSnapshot,
159
+ tui,
160
+ theme,
161
+ done,
162
+ statusShortcut,
163
+ );
164
+ view.start();
165
+ return view;
166
+ },
167
+ {
168
+ overlay: true,
169
+ overlayOptions: {
170
+ anchor: 'center',
171
+ width: '95%',
172
+ maxHeight: '95%',
173
+ margin: 1,
174
+ },
175
+ },
176
+ );
90
177
  }
91
178
 
92
179
  export class WorkflowStatusView implements Component {
93
180
  private timer: ReturnType<typeof setInterval> | undefined;
94
181
  private closed = false;
182
+ private scrollOffset = 0;
183
+ private viewportRows = 0;
184
+ private contentRows = 0;
185
+ private readonly statusShortcutLabel: string;
95
186
 
96
187
  constructor(
97
188
  private readonly getSnapshot: SnapshotProvider,
98
- private readonly tui: Pick<TUI, 'requestRender'>,
189
+ private readonly tui: StatusViewTui,
99
190
  private readonly theme: Theme,
100
191
  private readonly done: () => void,
101
- ) {}
192
+ private readonly statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
193
+ ) {
194
+ this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
195
+ }
102
196
 
103
197
  start(): void {
104
198
  this.timer = setInterval(
@@ -121,9 +215,25 @@ export class WorkflowStatusView implements Component {
121
215
  data === 'Q' ||
122
216
  matchesKey(data, 'escape') ||
123
217
  matchesKey(data, 'ctrl+c') ||
124
- matchesKey(data, 'ctrl+d')
218
+ matchesKey(data, 'ctrl+d') ||
219
+ matchesKey(data, this.statusShortcut)
125
220
  ) {
126
221
  this.close();
222
+ return;
223
+ }
224
+ const pageSize = Math.max(1, this.viewportRows - 2);
225
+ if (matchesKey(data, Key.down) || data === 'j') {
226
+ this.scrollBy(1);
227
+ } else if (matchesKey(data, Key.up) || data === 'k') {
228
+ this.scrollBy(-1);
229
+ } else if (matchesKey(data, Key.pageDown)) {
230
+ this.scrollBy(pageSize);
231
+ } else if (matchesKey(data, Key.pageUp)) {
232
+ this.scrollBy(-pageSize);
233
+ } else if (matchesKey(data, Key.home)) {
234
+ this.setScrollOffset(0);
235
+ } else if (matchesKey(data, Key.end)) {
236
+ this.setScrollOffset(Number.MAX_SAFE_INTEGER);
127
237
  }
128
238
  }
129
239
 
@@ -132,18 +242,65 @@ export class WorkflowStatusView implements Component {
132
242
  const snapshot = this.getSnapshot();
133
243
  if (viewportWidth < 12) {
134
244
  const label = snapshot
135
- ? `${statusGlyph(this.theme, snapshot.run.status)} ${snapshot.run.workflowId} ${statusLabel(snapshot.run.status)}`
245
+ ? `${statusGlyph(this.theme, runDisplayStatus(snapshot.run), snapshot.now)} ${snapshot.run.workflowId} ${statusLabel(snapshot.run.status)}`
136
246
  : 'No workflow';
137
247
  return [truncateToWidth(label, viewportWidth, '…', true)];
138
248
  }
139
249
 
140
250
  const contentWidth = viewportWidth - 2;
141
251
  const lines = snapshot
142
- ? renderBoard(this.theme, snapshot, contentWidth)
252
+ ? renderBoard(
253
+ this.theme,
254
+ snapshot,
255
+ contentWidth,
256
+ false,
257
+ this.statusShortcutLabel,
258
+ )
143
259
  : renderEmptyBoard(this.theme, contentWidth);
144
- return lines.map((line) =>
260
+ const rendered = lines.map((line) =>
145
261
  padAnsi(truncateToWidth(line, contentWidth, '…'), viewportWidth),
146
262
  );
263
+ return this.paginate(rendered, viewportWidth);
264
+ }
265
+
266
+ private paginate(lines: string[], width: number): string[] {
267
+ this.contentRows = lines.length;
268
+ const terminalRows = this.tui.terminal?.rows;
269
+ const maximumRows =
270
+ terminalRows === undefined
271
+ ? lines.length + 1
272
+ : Math.max(4, Math.floor(terminalRows * 0.95));
273
+ this.viewportRows = maximumRows;
274
+ const contentHeight = Math.max(1, maximumRows - 1);
275
+ const maximumOffset = Math.max(0, lines.length - contentHeight);
276
+ this.scrollOffset = Math.min(this.scrollOffset, maximumOffset);
277
+ const visible = lines.slice(
278
+ this.scrollOffset,
279
+ this.scrollOffset + contentHeight,
280
+ );
281
+ const first = lines.length === 0 ? 0 : this.scrollOffset + 1;
282
+ const last = Math.min(lines.length, this.scrollOffset + contentHeight);
283
+ const hint =
284
+ maximumOffset > 0
285
+ ? `↑/↓ PgUp/PgDn Home/End · rows ${first}-${last}/${lines.length} · ${this.statusShortcutLabel} / q / Esc hide`
286
+ : `${this.statusShortcutLabel} / q / Esc hide · live refresh`;
287
+ return [
288
+ ...visible,
289
+ padAnsi(truncateToWidth(this.theme.fg('dim', hint), width, '…'), width),
290
+ ];
291
+ }
292
+
293
+ private scrollBy(delta: number): void {
294
+ this.setScrollOffset(this.scrollOffset + delta);
295
+ }
296
+
297
+ private setScrollOffset(value: number): void {
298
+ const contentHeight = Math.max(1, this.viewportRows - 1);
299
+ const maximumOffset = Math.max(0, this.contentRows - contentHeight);
300
+ const next = Math.max(0, Math.min(value, maximumOffset));
301
+ if (next === this.scrollOffset) return;
302
+ this.scrollOffset = next;
303
+ this.tui.requestRender(true);
147
304
  }
148
305
 
149
306
  private close(): void {
@@ -159,6 +316,8 @@ function renderBoard(
159
316
  theme: Theme,
160
317
  snapshot: WorkflowStatusSnapshot,
161
318
  width: number,
319
+ showCloseHint = true,
320
+ statusShortcutLabel = formatShortcutLabel(DEFAULT_STATUS_SHORTCUT),
162
321
  ): string[] {
163
322
  const header = boxed(
164
323
  theme,
@@ -206,13 +365,14 @@ function renderBoard(
206
365
  ];
207
366
  }
208
367
 
209
- return [
210
- ...header,
211
- '',
212
- ...body,
213
- '',
214
- theme.fg('dim', 'q / Esc close · live refresh'),
215
- ];
368
+ const lines = [...header, '', ...body];
369
+ if (showCloseHint) {
370
+ lines.push(
371
+ '',
372
+ theme.fg('dim', `${statusShortcutLabel} / q / Esc hide · live refresh`),
373
+ );
374
+ }
375
+ return lines;
216
376
  }
217
377
 
218
378
  function renderEmptyBoard(theme: Theme, width: number): string[] {
@@ -242,7 +402,7 @@ function renderHeaderLines(
242
402
  `${run.history.length} completed attempt${run.history.length === 1 ? '' : 's'}`,
243
403
  );
244
404
  const firstLine = [
245
- statusGlyph(theme, run.status),
405
+ statusGlyph(theme, runDisplayStatus(run), snapshot.now),
246
406
  theme.bold(workflowName),
247
407
  status,
248
408
  theme.fg('muted', '·'),
@@ -336,18 +496,38 @@ function renderSummaryLines(
336
496
  }
337
497
  if (run.pauseReason) {
338
498
  lines.push(
339
- ...keyValueLines(
340
- theme,
341
- 'reason',
342
- run.pauseReason,
499
+ ...clampRows(
500
+ keyValueLines(
501
+ theme,
502
+ 'reason',
503
+ run.pauseReason,
504
+ width,
505
+ run.status === 'aborted' ? 'error' : 'warning',
506
+ ),
507
+ MAX_REASON_ROWS,
343
508
  width,
344
- run.status === 'aborted' ? 'error' : 'warning',
509
+ theme,
345
510
  ),
346
511
  );
347
512
  }
348
513
  return lines;
349
514
  }
350
515
 
516
+ function clampRows(
517
+ lines: string[],
518
+ maximum: number,
519
+ width: number,
520
+ theme: Theme,
521
+ ): string[] {
522
+ if (lines.length <= maximum) return lines;
523
+ const visible = lines.slice(0, maximum);
524
+ const last = visible.at(-1) ?? '';
525
+ visible[maximum - 1] =
526
+ truncateToWidth(last, Math.max(1, width - 1), '', true) +
527
+ theme.fg('dim', '…');
528
+ return visible;
529
+ }
530
+
351
531
  function renderPathLines(
352
532
  theme: Theme,
353
533
  snapshot: WorkflowStatusSnapshot,
@@ -372,7 +552,7 @@ function renderPathLines(
372
552
  for (const entry of visible) {
373
553
  const visit =
374
554
  entry.visit > 1 ? theme.fg('dim', ` · visit ${entry.visit}`) : '';
375
- const left = `${statusGlyph(theme, entry.status)} ${theme.fg(
555
+ const left = `${statusGlyph(theme, entry.status, snapshot.now)} ${theme.fg(
376
556
  entry.current ? 'text' : 'muted',
377
557
  entry.title,
378
558
  )}${visit}`;
@@ -407,7 +587,7 @@ function buildPathEntries(snapshot: WorkflowStatusSnapshot): PathEntry[] {
407
587
  entries.push({
408
588
  stepId: run.currentStepId,
409
589
  title: stepTitle(workflow, run.currentStepId),
410
- status: run.status,
590
+ status: runDisplayStatus(run),
411
591
  visit: Math.max(
412
592
  visits.get(run.currentStepId) ?? 0,
413
593
  run.visits[run.currentStepId] ?? 1,
@@ -525,13 +705,21 @@ function padAnsi(value: string, width: number): string {
525
705
  return `${value}${' '.repeat(width - visible)}`;
526
706
  }
527
707
 
528
- function statusGlyph(theme: Theme, status: StepDisplayStatus): string {
708
+ function statusGlyph(
709
+ theme: Theme,
710
+ status: StepDisplayStatus,
711
+ now = Date.now(),
712
+ ): string {
529
713
  if (status === 'completed') return theme.fg('success', '✓');
530
- if (status === 'running') return theme.fg('accent', '↻');
714
+ if (status === 'running') {
715
+ return theme.fg('accent', workingIcon(now));
716
+ }
531
717
  if (status === 'paused' || status === 'awaiting-gate') {
532
718
  return theme.fg('warning', '◆');
533
719
  }
534
- if (status === 'aborted') return theme.fg('error', '');
720
+ if (status === 'failed' || status === 'aborted') {
721
+ return theme.fg('error', '✕');
722
+ }
535
723
  return theme.fg('dim', '•');
536
724
  }
537
725
 
@@ -539,7 +727,7 @@ function statusColor(status: StepDisplayStatus): ThemeColor {
539
727
  if (status === 'completed') return 'success';
540
728
  if (status === 'running') return 'accent';
541
729
  if (status === 'paused' || status === 'awaiting-gate') return 'warning';
542
- if (status === 'aborted') return 'error';
730
+ if (status === 'failed' || status === 'aborted') return 'error';
543
731
  return 'dim';
544
732
  }
545
733
 
@@ -553,6 +741,27 @@ function statusBadge(theme: Theme, status: StepDisplayStatus): string {
553
741
  return theme.fg(statusColor(status), theme.bold(`[${statusLabel(status)}]`));
554
742
  }
555
743
 
744
+ function runDisplayStatus(run: WorkflowRun): StepDisplayStatus {
745
+ return run.status === 'paused' && run.failedStepId === run.currentStepId
746
+ ? 'failed'
747
+ : run.status;
748
+ }
749
+
750
+ export function workflowStatusIcon(run: WorkflowRun, now = Date.now()): string {
751
+ const status = runDisplayStatus(run);
752
+ if (status === 'completed') return '✓';
753
+ if (status === 'running') return workingIcon(now);
754
+ if (status === 'failed' || status === 'aborted') return '✕';
755
+ if (status === 'paused' || status === 'awaiting-gate') return '◆';
756
+ return '•';
757
+ }
758
+
759
+ function workingIcon(now: number): string {
760
+ return WORKING_ICON_FRAMES[
761
+ Math.floor(now / WORKING_ICON_FRAME_MS) % WORKING_ICON_FRAMES.length
762
+ ]!;
763
+ }
764
+
556
765
  function stepTitle(
557
766
  workflow: LoadedWorkflow | undefined,
558
767
  stepId: string,