@hs-x/cli 0.4.11 → 0.4.13

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.
Files changed (122) hide show
  1. package/README.md +21 -7
  2. package/dist/cloudflare-workers-logs.d.ts +1 -0
  3. package/dist/cloudflare-workers-logs.d.ts.map +1 -1
  4. package/dist/cloudflare-workers-logs.js +14 -3
  5. package/dist/cloudflare-workers-logs.js.map +1 -1
  6. package/dist/commands/completion.d.ts.map +1 -1
  7. package/dist/commands/completion.js +3 -0
  8. package/dist/commands/completion.js.map +1 -1
  9. package/dist/commands/connect.d.ts.map +1 -1
  10. package/dist/commands/connect.js +2 -1
  11. package/dist/commands/connect.js.map +1 -1
  12. package/dist/commands/deploy.d.ts +16 -0
  13. package/dist/commands/deploy.d.ts.map +1 -1
  14. package/dist/commands/deploy.js +95 -13
  15. package/dist/commands/deploy.js.map +1 -1
  16. package/dist/commands/dev.d.ts +1 -0
  17. package/dist/commands/dev.d.ts.map +1 -1
  18. package/dist/commands/dev.js +368 -16
  19. package/dist/commands/dev.js.map +1 -1
  20. package/dist/commands/help-command.d.ts.map +1 -1
  21. package/dist/commands/help-command.js +22 -6
  22. package/dist/commands/help-command.js.map +1 -1
  23. package/dist/commands/project-lifecycle.d.ts +10 -0
  24. package/dist/commands/project-lifecycle.d.ts.map +1 -1
  25. package/dist/commands/project-lifecycle.js +120 -33
  26. package/dist/commands/project-lifecycle.js.map +1 -1
  27. package/dist/commands/project.d.ts +3 -0
  28. package/dist/commands/project.d.ts.map +1 -1
  29. package/dist/commands/project.js +2 -0
  30. package/dist/commands/project.js.map +1 -1
  31. package/dist/constants.d.ts +1 -1
  32. package/dist/constants.js +1 -1
  33. package/dist/dev/account-selection.d.ts +55 -0
  34. package/dist/dev/account-selection.d.ts.map +1 -1
  35. package/dist/dev/account-selection.js +120 -0
  36. package/dist/dev/account-selection.js.map +1 -1
  37. package/dist/dev/browser-log-bridge.d.ts +13 -8
  38. package/dist/dev/browser-log-bridge.d.ts.map +1 -1
  39. package/dist/dev/browser-log-bridge.js +37 -17
  40. package/dist/dev/browser-log-bridge.js.map +1 -1
  41. package/dist/dev/clipboard.d.ts +9 -0
  42. package/dist/dev/clipboard.d.ts.map +1 -0
  43. package/dist/dev/clipboard.js +42 -0
  44. package/dist/dev/clipboard.js.map +1 -0
  45. package/dist/dev/compat-shim.d.ts +19 -4
  46. package/dist/dev/compat-shim.d.ts.map +1 -1
  47. package/dist/dev/compat-shim.js +31 -4
  48. package/dist/dev/compat-shim.js.map +1 -1
  49. package/dist/dev/component-selection.d.ts +11 -0
  50. package/dist/dev/component-selection.d.ts.map +1 -1
  51. package/dist/dev/component-selection.js +23 -0
  52. package/dist/dev/component-selection.js.map +1 -1
  53. package/dist/dev/frontend-log-relay.d.ts +12 -0
  54. package/dist/dev/frontend-log-relay.d.ts.map +1 -1
  55. package/dist/dev/frontend-log-relay.js +31 -20
  56. package/dist/dev/frontend-log-relay.js.map +1 -1
  57. package/dist/dev/hubspot-logs-poller.d.ts +7 -1
  58. package/dist/dev/hubspot-logs-poller.d.ts.map +1 -1
  59. package/dist/dev/hubspot-logs-poller.js +28 -1
  60. package/dist/dev/hubspot-logs-poller.js.map +1 -1
  61. package/dist/dev/keyboard.d.ts +21 -3
  62. package/dist/dev/keyboard.d.ts.map +1 -1
  63. package/dist/dev/keyboard.js +96 -4
  64. package/dist/dev/keyboard.js.map +1 -1
  65. package/dist/dev/local-proxy.d.ts +90 -0
  66. package/dist/dev/local-proxy.d.ts.map +1 -0
  67. package/dist/dev/local-proxy.js +186 -0
  68. package/dist/dev/local-proxy.js.map +1 -0
  69. package/dist/dev/log-server.d.ts +9 -0
  70. package/dist/dev/log-server.d.ts.map +1 -1
  71. package/dist/dev/log-server.js +46 -3
  72. package/dist/dev/log-server.js.map +1 -1
  73. package/dist/dev/production-app-check.d.ts +23 -0
  74. package/dist/dev/production-app-check.d.ts.map +1 -0
  75. package/dist/dev/production-app-check.js +28 -0
  76. package/dist/dev/production-app-check.js.map +1 -0
  77. package/dist/dev/session-manager.d.ts +49 -1
  78. package/dist/dev/session-manager.d.ts.map +1 -1
  79. package/dist/dev/session-manager.js +152 -12
  80. package/dist/dev/session-manager.js.map +1 -1
  81. package/dist/dev/stream-event-format.d.ts +10 -1
  82. package/dist/dev/stream-event-format.d.ts.map +1 -1
  83. package/dist/dev/stream-event-format.js +157 -5
  84. package/dist/dev/stream-event-format.js.map +1 -1
  85. package/dist/dev/stream-renderer.d.ts +34 -1
  86. package/dist/dev/stream-renderer.d.ts.map +1 -1
  87. package/dist/dev/stream-renderer.js +371 -60
  88. package/dist/dev/stream-renderer.js.map +1 -1
  89. package/dist/dev/trace-markdown.d.ts +25 -0
  90. package/dist/dev/trace-markdown.d.ts.map +1 -0
  91. package/dist/dev/trace-markdown.js +159 -0
  92. package/dist/dev/trace-markdown.js.map +1 -0
  93. package/dist/errors-registry.d.ts.map +1 -1
  94. package/dist/errors-registry.js +15 -0
  95. package/dist/errors-registry.js.map +1 -1
  96. package/dist/hubspot-app-logs.d.ts +9 -0
  97. package/dist/hubspot-app-logs.d.ts.map +1 -1
  98. package/dist/hubspot-app-logs.js +6 -4
  99. package/dist/hubspot-app-logs.js.map +1 -1
  100. package/dist/hubspot-auth.d.ts.map +1 -1
  101. package/dist/hubspot-auth.js +15 -4
  102. package/dist/hubspot-auth.js.map +1 -1
  103. package/dist/hubspot-developer-client.js +3 -0
  104. package/dist/hubspot-developer-client.js.map +1 -1
  105. package/dist/hubspot-project.d.ts +2 -0
  106. package/dist/hubspot-project.d.ts.map +1 -1
  107. package/dist/hubspot-project.js +13 -1
  108. package/dist/hubspot-project.js.map +1 -1
  109. package/dist/prompt.d.ts +6 -0
  110. package/dist/prompt.d.ts.map +1 -1
  111. package/dist/prompt.js +20 -0
  112. package/dist/prompt.js.map +1 -1
  113. package/dist/reporter/human.d.ts.map +1 -1
  114. package/dist/reporter/human.js +6 -1
  115. package/dist/reporter/human.js.map +1 -1
  116. package/dist/reporter/style.d.ts +10 -0
  117. package/dist/reporter/style.d.ts.map +1 -1
  118. package/dist/reporter/style.js +39 -0
  119. package/dist/reporter/style.js.map +1 -1
  120. package/dist/services/hubspot-dev-client.js +3 -0
  121. package/dist/services/hubspot-dev-client.js.map +1 -1
  122. package/package.json +8 -8
@@ -13,14 +13,41 @@
13
13
  * surface (`setFilter` / `pause` / `setPrompt`) that the keyboard controller
14
14
  * in keyboard.ts drives. Non-TTY output is untouched: no status line, no
15
15
  * escape codes, every event printed.
16
+ *
17
+ * Details mode (`d` / ↑) swaps the stream for a browser: a list of the
18
+ * retained matching events on one side and the untruncated details of the
19
+ * selected one on the other, in the spirit of a clipboard-history picker.
20
+ * Up/down move the selection, page keys scroll the detail pane, and new
21
+ * events keep arriving in the list while the selection stays pinned.
22
+ *
23
+ * The browser lives in the terminal's alternate screen buffer (the way
24
+ * `less` and `vim` do). Repaints happen in place there, so neither
25
+ * navigation nor arriving events grow the scrollback — some terminals
26
+ * (alacritty-based ones such as Zed's) push a cleared primary screen into
27
+ * history, which turned every keypress into a screenful to scroll past.
28
+ * Leaving restores the stream exactly as it was and appends whatever
29
+ * arrived meanwhile, so nothing is repainted on the way out.
16
30
  */
17
- import { detectColor, detectUnicode, makeStyle, symbolsFor, wrapVisible, } from '../reporter/style.js';
18
- import { devEventFingerprint, formatDevStreamEvent, renderDevEventDetails, } from './stream-event-format.js';
31
+ import { detectColor, detectUnicode, makeStyle, padVisible, symbolsFor, truncateVisible, wrapVisible, } from '../reporter/style.js';
32
+ import { copyTextToClipboard } from './clipboard.js';
33
+ import { devEventFingerprint, formatDevDetailsListRow, formatDevStreamEvent, renderDevEventDetails, } from './stream-event-format.js';
19
34
  import { EMPTY_FILTER, describeFilter, isEmptyFilter, matchesFilter, } from './stream-filter.js';
35
+ import { renderDevTraceMarkdown, selectDevTrace } from './trace-markdown.js';
20
36
  const ERASE_LINE = '\r\x1b[2K';
21
- const CLEAR_SCREEN = '\x1b[2J\x1b[H';
37
+ /**
38
+ * Home the cursor and erase to the end of the screen. Unlike `ESC[2J` this
39
+ * never pushes the visible rows into scrollback, so filter repaints overwrite
40
+ * the view in place instead of stacking screenfuls of history.
41
+ */
42
+ const REPAINT_SCREEN = '\x1b[H\x1b[0J';
43
+ const ALT_SCREEN_ENTER = '\x1b[?1049h\x1b[?25l';
44
+ const ALT_SCREEN_LEAVE = '\x1b[?25h\x1b[?1049l';
22
45
  const HISTORY_LIMIT = 500;
23
46
  const HISTORY_EVENT_MAX_LINES = 12;
47
+ /** Terminals at least this wide get the list beside the detail pane. */
48
+ const SIDE_BY_SIDE_MIN_COLUMNS = 90;
49
+ /** Stacked layout: the list keeps a few rows and the detail pane takes the rest. */
50
+ const STACKED_LIST_MAX_ROWS = 8;
24
51
  export function startDevStreamRenderer(opts) {
25
52
  const stderr = opts.stderr ?? process.stderr;
26
53
  const env = opts.env ?? process.env;
@@ -28,6 +55,7 @@ export function startDevStreamRenderer(opts) {
28
55
  const color = detectColor(env, isTTY);
29
56
  const style = makeStyle(color, detectUnicode(env));
30
57
  const sym = symbolsFor(style.unicode);
58
+ const copyText = opts.copyText ?? copyTextToClipboard;
31
59
  // Cursor control is a terminal capability, independent of color. NO_COLOR
32
60
  // must remove SGR styling without hiding prompts, filters, pause state, or
33
61
  // the key map. Piped/non-TTY output remains plain append-only text.
@@ -40,8 +68,9 @@ export function startDevStreamRenderer(opts) {
40
68
  let prompt;
41
69
  let statusShown = false;
42
70
  let lastStatusWidth = 0;
43
- let detailEntry;
44
- let detailPending = 0;
71
+ let details;
72
+ let detailsRepaintQueued = false;
73
+ let stopped = false;
45
74
  let lastWritten;
46
75
  const pauseBuffer = [];
47
76
  const startupBuffer = [];
@@ -60,6 +89,15 @@ export function startDevStreamRenderer(opts) {
60
89
  const terminalRows = stderr.rows;
61
90
  return typeof terminalRows === 'number' && terminalRows > 0 ? terminalRows : 24;
62
91
  };
92
+ const matchingEntries = () => history.filter((entry) => matchesFilter(filter, entry.event));
93
+ const detailEntries = () => {
94
+ const matching = matchingEntries();
95
+ if (!details?.related)
96
+ return matching;
97
+ const related = new Set(selectDevTrace(history, details.selected).entries);
98
+ return matching.filter((entry) => related.has(entry));
99
+ };
100
+ const separator = style.dim(` ${sym.bullet} `);
63
101
  // Compose from plain segments and paint at the end, so the line can be
64
102
  // fitted to the terminal width without ever cutting through an ANSI code —
65
103
  // a wrapped status line is unerasable and smears on every redraw.
@@ -71,25 +109,40 @@ export function startDevStreamRenderer(opts) {
71
109
  return line.length > width ? line.slice(0, Math.max(0, width)) : line;
72
110
  }
73
111
  const scope = describeFilter(filter);
74
- const segments = detailEntry
75
- ? [
76
- {
77
- text: `detail: ${detailEntry.event.lane}`,
78
- painted: `${style.dim('detail:')} ${style.orange(detailEntry.event.lane)}`,
79
- },
80
- ]
81
- : [
82
- {
112
+ const segments = [];
113
+ if (details) {
114
+ const matching = detailEntries();
115
+ const index = Math.max(0, matching.indexOf(details.selected));
116
+ const position = `${index + 1}/${matching.length}`;
117
+ segments.push({
118
+ text: `event ${position} ${details.selected.event.lane}`,
119
+ painted: `${style.dim('event')} ${position} ${style.orange(details.selected.event.lane)}`,
120
+ });
121
+ if (!isEmptyFilter(filter)) {
122
+ segments.push({
83
123
  text: `view: ${scope}`,
84
- painted: `${style.dim('view:')} ${isEmptyFilter(filter) ? style.dim(scope) : style.orange(scope)}`,
85
- },
86
- ];
87
- if (detailEntry && detailPending > 0) {
88
- const text = `${detailPending} new`;
89
- segments.push({ text, painted: style.dim(text) });
124
+ painted: `${style.dim('view:')} ${style.orange(scope)}`,
125
+ });
126
+ }
127
+ if (details.pending > 0) {
128
+ const text = `${details.pending} new`;
129
+ segments.push({ text, painted: style.yellow(text) });
130
+ }
131
+ if (details.related) {
132
+ segments.push({ text: 'trace', painted: style.cyan('trace') });
133
+ }
134
+ if (details.notice) {
135
+ segments.push({ text: details.notice, painted: style.green(details.notice) });
136
+ }
90
137
  }
91
- else if (!detailEntry && hidden > 0) {
92
- segments.push({ text: `${hidden} hidden`, painted: style.dim(`${hidden} hidden`) });
138
+ else {
139
+ segments.push({
140
+ text: `view: ${scope}`,
141
+ painted: `${style.dim('view:')} ${isEmptyFilter(filter) ? style.dim(scope) : style.orange(scope)}`,
142
+ });
143
+ if (hidden > 0) {
144
+ segments.push({ text: `${hidden} hidden`, painted: style.dim(`${hidden} hidden`) });
145
+ }
93
146
  }
94
147
  if (paused) {
95
148
  const bufferedCount = retainedCount(pauseBuffer);
@@ -109,11 +162,17 @@ export function startDevStreamRenderer(opts) {
109
162
  }
110
163
  // Keep the footer focused on the less-obvious view controls. Help and quit
111
164
  // remain available from the keyboard without occupying persistent space.
112
- const hintTiers = detailEntry
113
- ? ['[d] back']
165
+ const arrows = style.unicode ? '↑↓' : 'up/down';
166
+ const hintTiers = details
167
+ ? [
168
+ `[${arrows}] move [y] copy trace [t] related [pgup/pgdn] scroll [esc] back`,
169
+ `[${arrows}] move [y] copy [t] related [esc] back`,
170
+ '[y] copy [t] related [esc] back',
171
+ '[esc] back',
172
+ ]
114
173
  : [
115
- '[f] lane [e] errors [/] search [d] details [c] reset [p] pause',
116
- '[f] [e] [/] [d] [c] [p]',
174
+ `[${arrows}] browse [f] lane [e] errors [/] search [c] reset [p] pause`,
175
+ `[${arrows}] [f] [e] [/] [c] [p]`,
117
176
  ];
118
177
  for (const hints of hintTiers) {
119
178
  const cost = sepWidth + hints.length;
@@ -131,7 +190,7 @@ export function startDevStreamRenderer(opts) {
131
190
  used = prefixWidth + cut.length;
132
191
  }
133
192
  lastStatusWidth = used;
134
- return ` ${style.orange(sym.rail)} ${kept.map((segment) => segment.painted).join(style.dim(` ${sym.bullet} `))}`;
193
+ return ` ${style.orange(sym.rail)} ${kept.map((segment) => segment.painted).join(separator)}`;
135
194
  };
136
195
  const drawStatus = () => {
137
196
  if (!statusEnabled || statusHeld)
@@ -146,6 +205,11 @@ export function startDevStreamRenderer(opts) {
146
205
  const onResize = () => {
147
206
  if (!statusEnabled)
148
207
  return;
208
+ if (details) {
209
+ // The primary screen is untouched underneath; only the browser repaints.
210
+ renderDetails();
211
+ return;
212
+ }
149
213
  lastWritten = undefined;
150
214
  if (statusShown) {
151
215
  stderr.write(ERASE_LINE);
@@ -195,9 +259,16 @@ export function startDevStreamRenderer(opts) {
195
259
  if (previous?.fingerprint === fingerprint) {
196
260
  previous.event = event;
197
261
  previous.count += 1;
262
+ previous.lastTs = event.ts;
198
263
  return { entry: previous, repeated: true };
199
264
  }
200
- const entry = { event, fingerprint, count: 1 };
265
+ const entry = {
266
+ event,
267
+ fingerprint,
268
+ count: 1,
269
+ firstTs: event.ts,
270
+ lastTs: event.ts,
271
+ };
201
272
  history.push(entry);
202
273
  if (history.length <= HISTORY_LIMIT)
203
274
  return { entry, repeated: false };
@@ -220,7 +291,7 @@ export function startDevStreamRenderer(opts) {
220
291
  return;
221
292
  }
222
293
  eraseStatus();
223
- const matching = history.filter((entry) => matchesFilter(filter, entry.event));
294
+ const matching = matchingEntries();
224
295
  const rowBudget = Math.max(8, rows() - 4);
225
296
  const selected = [];
226
297
  let usedRows = 0;
@@ -245,8 +316,8 @@ export function startDevStreamRenderer(opts) {
245
316
  const count = matching.length === selected.length
246
317
  ? `${matchingCount} recent event${matchingCount === 1 ? '' : 's'}`
247
318
  : `latest ${selectedCount} of ${matchingCount} recent events`;
248
- stderr.write(CLEAR_SCREEN);
249
- stderr.write(` ${style.bold('hs-x dev logs')} ${style.dim(style.unicode ? '·' : '-')} ${isEmptyFilter(filter) ? style.dim(scope) : style.orange(scope)} ${style.dim(style.unicode ? '·' : '-')} ${style.dim(count)}\n`);
319
+ stderr.write(REPAINT_SCREEN);
320
+ stderr.write(` ${style.bold('hs-x dev logs')} ${separator}${isEmptyFilter(filter) ? style.dim(scope) : style.orange(scope)}${separator}${style.dim(count)}\n`);
250
321
  if (selected.length === 0) {
251
322
  const detail = filter.texts.length > 0
252
323
  ? `No events match ${filter.texts.map((text) => `“${text}”`).join(' or ')}. Search is case-insensitive.`
@@ -268,11 +339,169 @@ export function startDevStreamRenderer(opts) {
268
339
  : undefined;
269
340
  drawStatus();
270
341
  };
342
+ /** One compact list row: the stream line's headline, no continuations. */
343
+ const listRow = (entry, selected, width) => {
344
+ const pointer = selected ? style.orange(style.unicode ? '▸' : '>') : ' ';
345
+ return truncateVisible(`${pointer} ${formatDevDetailsListRow(entry.event, entry.count, style)}`, width);
346
+ };
347
+ /**
348
+ * Paint the details browser: header, the list beside (or above) the detail
349
+ * pane, then the status line. Everything is derived from retained history,
350
+ * so it can be repainted after any change — selection, scroll, new event,
351
+ * filter, resize — without tracking what is on screen.
352
+ */
353
+ const renderDetails = () => {
354
+ if (!details || !statusEnabled || statusHeld)
355
+ return;
356
+ const matching = detailEntries();
357
+ if (matching.length === 0) {
358
+ if (details.related) {
359
+ details.related = false;
360
+ details.notice = 'no related events match the active filter';
361
+ renderDetails();
362
+ return;
363
+ }
364
+ closeDetails();
365
+ return;
366
+ }
367
+ let index = matching.indexOf(details.selected);
368
+ if (index < 0) {
369
+ // The pinned entry was evicted from history or no longer matches.
370
+ index = matching.length - 1;
371
+ details.selected = matching[index];
372
+ details.scroll = 0;
373
+ }
374
+ const isNewest = index === matching.length - 1;
375
+ if (isNewest)
376
+ details.pending = 0;
377
+ const width = columns();
378
+ const height = rows();
379
+ // Header, blank rule, and the status line on the last row (written
380
+ // without a newline so the alternate screen never scrolls).
381
+ const bodyRows = Math.max(6, height - 3);
382
+ const sideBySide = width >= SIDE_BY_SIDE_MIN_COLUMNS;
383
+ const listWidth = sideBySide ? Math.min(56, Math.max(36, Math.floor(width * 0.38))) : width - 1;
384
+ const listRows = sideBySide
385
+ ? bodyRows
386
+ : Math.min(matching.length, Math.max(3, Math.min(STACKED_LIST_MAX_ROWS, Math.floor(bodyRows * 0.35))));
387
+ const detailWidth = sideBySide ? width - listWidth - 3 : width;
388
+ const detailRows = sideBySide ? bodyRows : bodyRows - listRows - 1;
389
+ if (index < details.listTop)
390
+ details.listTop = index;
391
+ if (index >= details.listTop + listRows)
392
+ details.listTop = index - listRows + 1;
393
+ details.listTop = Math.max(0, Math.min(details.listTop, matching.length - listRows));
394
+ const listLines = [];
395
+ for (let row = 0; row < listRows; row += 1) {
396
+ const entry = matching[details.listTop + row];
397
+ listLines.push(entry ? listRow(entry, entry === details.selected, listWidth) : '');
398
+ }
399
+ const above = details.listTop;
400
+ const below = Math.max(0, matching.length - (details.listTop + listRows));
401
+ if (above > 0 && listLines.length > 0) {
402
+ listLines[0] = truncateVisible(` ${style.dim(`… ${above} older`)}`, listWidth);
403
+ }
404
+ if (below > 0 && listLines.length > 1) {
405
+ listLines[listLines.length - 1] = truncateVisible(` ${style.dim(`… ${below} newer`)}`, listWidth);
406
+ }
407
+ const detailLines = renderDevEventDetails(details.selected.event, details.selected.count, style, detailWidth, { firstTs: details.selected.firstTs, lastTs: details.selected.lastTs }).split('\n');
408
+ const maxScroll = Math.max(0, detailLines.length - detailRows);
409
+ details.scroll = Math.max(0, Math.min(details.scroll, maxScroll));
410
+ const pane = detailLines.slice(details.scroll, details.scroll + detailRows);
411
+ if (details.scroll > 0 && pane.length > 0) {
412
+ pane[0] = ` ${style.dim(`… ${plural(details.scroll, 'line')} above (pgup)`)}`;
413
+ }
414
+ const remaining = detailLines.length - (details.scroll + pane.length);
415
+ if (remaining > 0 && pane.length > 1) {
416
+ pane[pane.length - 1] = ` ${style.dim(`… ${plural(remaining, 'line')} more (pgdn)`)}`;
417
+ }
418
+ const paneLines = pane.map((line) => truncateVisible(line, detailWidth));
419
+ const position = `${index + 1} of ${matching.length}`;
420
+ const scope = details.related ? 'trace' : details.selected.event.lane;
421
+ const header = ` ${style.bold('hs-x dev event')}${separator}${style.orange(scope)}${separator}${style.dim(position)}`;
422
+ const out = [truncateVisible(header, width - 1), ''];
423
+ if (sideBySide) {
424
+ const rail = style.dim(sym.rail);
425
+ for (let row = 0; row < bodyRows; row += 1) {
426
+ const left = padVisible(listLines[row] ?? '', listWidth);
427
+ const right = paneLines[row] ?? '';
428
+ out.push(`${left} ${rail} ${right}`.trimEnd());
429
+ }
430
+ }
431
+ else {
432
+ out.push(...listLines);
433
+ out.push(` ${style.dim((style.unicode ? '─' : '-').repeat(Math.max(8, width - 4)))}`);
434
+ out.push(...paneLines);
435
+ }
436
+ statusShown = false; // the repaint below erases the whole alternate screen
437
+ stderr.write(REPAINT_SCREEN);
438
+ stderr.write(`${out.join('\n')}\n`);
439
+ drawStatus();
440
+ };
441
+ // Bursts (a HubSpot poll batch, a stack trace) arrive as many synchronous
442
+ // emits; repaint the browser once after the burst instead of per event.
443
+ const queueDetailsRepaint = () => {
444
+ if (detailsRepaintQueued)
445
+ return;
446
+ detailsRepaintQueued = true;
447
+ queueMicrotask(() => {
448
+ detailsRepaintQueued = false;
449
+ if (!stopped && details)
450
+ renderDetails();
451
+ });
452
+ };
453
+ const openDetails = () => {
454
+ const latest = matchingEntries().at(-1);
455
+ if (!latest)
456
+ return false;
457
+ // Erase the primary status line first so it is not restored twice: the
458
+ // alternate screen switch saves the primary buffer and cursor as they are.
459
+ eraseStatus();
460
+ stderr.write(ALT_SCREEN_ENTER);
461
+ details = {
462
+ selected: latest,
463
+ listTop: 0,
464
+ scroll: 0,
465
+ pending: 0,
466
+ arrivals: [],
467
+ related: false,
468
+ notice: undefined,
469
+ };
470
+ renderDetails();
471
+ return true;
472
+ };
473
+ const closeDetails = () => {
474
+ if (!details)
475
+ return;
476
+ const { arrivals } = details;
477
+ details = undefined;
478
+ statusShown = false; // the alternate screen's status line vanishes with it
479
+ stderr.write(ALT_SCREEN_LEAVE);
480
+ hidden = retainedCount(history.filter((entry) => !matchesFilter(filter, entry.event)));
481
+ // The stream is back exactly as it was; append what arrived meanwhile.
482
+ for (const entry of arrivals) {
483
+ if (!matchesFilter(filter, entry.event))
484
+ continue;
485
+ if (paused)
486
+ pauseBuffer.push(entry);
487
+ else if (lastWritten?.entry === entry)
488
+ rewriteLastEvent(entry);
489
+ else
490
+ writeEvent(entry);
491
+ }
492
+ drawStatus();
493
+ };
271
494
  const onEvent = (event) => {
272
495
  const remembered = remember(event);
273
496
  if (statusHeld) {
274
497
  if (!statusEnabled) {
275
- startupBuffer.push({ event, fingerprint: remembered.entry.fingerprint, count: 1 });
498
+ startupBuffer.push({
499
+ event,
500
+ fingerprint: remembered.entry.fingerprint,
501
+ count: 1,
502
+ firstTs: event.ts,
503
+ lastTs: event.ts,
504
+ });
276
505
  }
277
506
  else if (!remembered.repeated) {
278
507
  startupBuffer.push(remembered.entry);
@@ -285,15 +514,22 @@ export function startDevStreamRenderer(opts) {
285
514
  drawStatus();
286
515
  return;
287
516
  }
288
- if (detailEntry) {
289
- detailPending += 1;
290
- if (statusEnabled)
291
- drawStatus();
517
+ if (details) {
518
+ details.pending += 1;
519
+ if (!details.arrivals.includes(remembered.entry))
520
+ details.arrivals.push(remembered.entry);
521
+ queueDetailsRepaint();
292
522
  return;
293
523
  }
294
524
  if (paused) {
295
525
  if (!statusEnabled) {
296
- pauseBuffer.push({ event, fingerprint: remembered.entry.fingerprint, count: 1 });
526
+ pauseBuffer.push({
527
+ event,
528
+ fingerprint: remembered.entry.fingerprint,
529
+ count: 1,
530
+ firstTs: event.ts,
531
+ lastTs: event.ts,
532
+ });
297
533
  }
298
534
  else if (!remembered.repeated) {
299
535
  pauseBuffer.push(remembered.entry);
@@ -310,6 +546,18 @@ export function startDevStreamRenderer(opts) {
310
546
  const unsubscribe = opts.bus.subscribe(onEvent);
311
547
  if (statusEnabled)
312
548
  drawStatus();
549
+ const applyFilter = (next) => {
550
+ filter = next;
551
+ if (details) {
552
+ // Stay in the browser: the list re-derives from the new filter and the
553
+ // selection snaps to the newest match if the pinned event dropped out.
554
+ details.listTop = 0;
555
+ hidden = retainedCount(history.filter((entry) => !matchesFilter(filter, entry.event)));
556
+ renderDetails();
557
+ return;
558
+ }
559
+ refreshHistoryView();
560
+ };
313
561
  return {
314
562
  ready() {
315
563
  if (!statusHeld)
@@ -329,17 +577,9 @@ export function startDevStreamRenderer(opts) {
329
577
  },
330
578
  hasStatusLine: () => statusEnabled,
331
579
  getFilter: () => filter,
332
- setFilter(next) {
333
- filter = next;
334
- detailEntry = undefined;
335
- detailPending = 0;
336
- refreshHistoryView();
337
- },
580
+ setFilter: applyFilter,
338
581
  resetFilter() {
339
- filter = initialFilter;
340
- detailEntry = undefined;
341
- detailPending = 0;
342
- refreshHistoryView();
582
+ applyFilter(initialFilter);
343
583
  },
344
584
  setPaused(next) {
345
585
  if (paused === next)
@@ -356,13 +596,11 @@ export function startDevStreamRenderer(opts) {
356
596
  },
357
597
  isPaused: () => paused,
358
598
  toggleLatestDetails() {
359
- if (detailEntry) {
360
- detailEntry = undefined;
361
- detailPending = 0;
362
- refreshHistoryView();
599
+ if (details) {
600
+ closeDetails();
363
601
  return;
364
602
  }
365
- const latest = history.findLast((entry) => matchesFilter(filter, entry.event));
603
+ const latest = matchingEntries().at(-1);
366
604
  if (!latest) {
367
605
  eraseStatus();
368
606
  stderr.write(` ${style.dim('No matching event is available for details.')}\n`);
@@ -374,14 +612,76 @@ export function startDevStreamRenderer(opts) {
374
612
  stderr.write(`${renderDevEventDetails(latest.event, latest.count, style, columns())}\n`);
375
613
  return;
376
614
  }
377
- detailEntry = latest;
378
- detailPending = 0;
379
- eraseStatus();
380
- stderr.write(CLEAR_SCREEN);
381
- stderr.write(` ${style.bold('hs-x dev event')} ${style.dim(style.unicode ? '·' : '-')} ${style.orange(latest.event.lane)} ${style.dim(style.unicode ? '·' : '-')} ${style.dim('latest matching event')}\n`);
382
- stderr.write(`${renderDevEventDetails(latest.event, latest.count, style, columns())}\n`);
383
- lastWritten = undefined;
384
- drawStatus();
615
+ openDetails();
616
+ },
617
+ isShowingDetails: () => details !== undefined,
618
+ moveDetailsSelection(delta) {
619
+ if (!details) {
620
+ if (!statusEnabled || !openDetails())
621
+ return;
622
+ // Entering with ↑ lands on the newest event; the move itself is the
623
+ // gesture, so a single press does not additionally step older.
624
+ return;
625
+ }
626
+ const matching = detailEntries();
627
+ const index = matching.indexOf(details.selected);
628
+ const next = Math.max(0, Math.min(matching.length - 1, (index < 0 ? matching.length - 1 : index) + delta));
629
+ const target = matching[next];
630
+ if (!target)
631
+ return;
632
+ if (target !== details.selected) {
633
+ details.selected = target;
634
+ details.scroll = 0;
635
+ details.notice = undefined;
636
+ }
637
+ renderDetails();
638
+ },
639
+ jumpDetailsSelection(edge) {
640
+ if (!details) {
641
+ if (!statusEnabled || !openDetails())
642
+ return;
643
+ if (edge === 'last')
644
+ return;
645
+ }
646
+ if (!details)
647
+ return;
648
+ const matching = detailEntries();
649
+ const target = edge === 'first' ? matching[0] : matching.at(-1);
650
+ if (!target)
651
+ return;
652
+ if (target !== details.selected) {
653
+ details.selected = target;
654
+ details.scroll = 0;
655
+ details.notice = undefined;
656
+ }
657
+ renderDetails();
658
+ },
659
+ scrollDetails(delta) {
660
+ if (!details)
661
+ return;
662
+ details.scroll = Math.max(0, details.scroll + delta);
663
+ details.notice = undefined;
664
+ renderDetails();
665
+ },
666
+ toggleRelatedDetails() {
667
+ if (!details)
668
+ return;
669
+ details.related = !details.related;
670
+ details.listTop = 0;
671
+ details.scroll = 0;
672
+ details.notice = details.related ? 'showing related events' : undefined;
673
+ renderDetails();
674
+ },
675
+ copyDetailsTrace() {
676
+ if (!details)
677
+ return;
678
+ const selection = selectDevTrace(history, details.selected);
679
+ const markdown = renderDevTraceMarkdown(selection, details.selected);
680
+ const result = copyText(markdown);
681
+ details.notice = result.ok
682
+ ? `copied ${selection.entries.length}-event trace as Markdown`
683
+ : `copy failed: ${result.reason}`;
684
+ renderDetails();
385
685
  },
386
686
  setPrompt(next) {
387
687
  prompt = next;
@@ -395,10 +695,18 @@ export function startDevStreamRenderer(opts) {
395
695
  drawStatus();
396
696
  },
397
697
  stop() {
698
+ stopped = true;
398
699
  unsubscribe();
399
700
  if (statusEnabled && typeof stderr.off === 'function') {
400
701
  stderr.off('resize', onResize);
401
702
  }
703
+ if (details) {
704
+ // Never leave the terminal stranded in the alternate screen.
705
+ details = undefined;
706
+ statusShown = false;
707
+ stderr.write(ALT_SCREEN_LEAVE);
708
+ return;
709
+ }
402
710
  eraseStatus();
403
711
  },
404
712
  };
@@ -412,6 +720,9 @@ function truncateRenderedEvent(rendered, maxLines, style) {
412
720
  ` ${style.dim(`… ${lines.length - maxLines + 1} more lines`)}`,
413
721
  ].join('\n');
414
722
  }
723
+ function plural(count, noun) {
724
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
725
+ }
415
726
  function retainedCount(entries) {
416
727
  return entries.reduce((total, entry) => total + entry.count, 0);
417
728
  }