@runwisp/ui 0.10.0 → 0.12.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.
@@ -3,7 +3,7 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import { untrack } from "svelte";
6
- import { ansiLineToHtml } from "../log-console/ansi.js";
6
+ import { ansiLineToHtml, visibleColumns } from "../log-console/ansi.js";
7
7
  import { LogCache } from "../log-console/LogCache.svelte.js";
8
8
  import { LogFetcher } from "../log-console/LogFetcher.svelte.js";
9
9
  import type { FetchLogsFn, LogEvent } from "../log-console/types.js";
@@ -19,6 +19,24 @@
19
19
  // The pulse is one-shot: changing the prop to a new line restarts the
20
20
  // animation; setting it to null clears immediately.
21
21
  highlightLine?: number | null;
22
+ // fetchLineHistory resolves the prior whole-region frames a settled
23
+ // progress bar / multi-line redraw passed through, for the given absolute
24
+ // line number. When omitted, anchor lines render without the rewind
25
+ // affordance.
26
+ fetchLineHistory?: ((lineNum: number) => Promise<string[][]>) | undefined;
27
+ // Text of the terminus marker drawn once a finished log's last line is
28
+ // reached (default "end of output"). A stopped/timed-out run passes its
29
+ // own wording so a short capture ends on an explicit reason, not a void.
30
+ endLabel?: string;
31
+ // "muted" (default) renders the terminus in the gutter grey; "warn"
32
+ // tints it amber for runs that ended by intervention rather than naturally.
33
+ endTone?: "muted" | "warn";
34
+ // Wrap long lines instead of horizontally scrolling them. When on, each
35
+ // rendered row's height becomes a multiple of `lineHeight` (one per
36
+ // wrapped visual row), so the virtualizer switches from the fixed-height
37
+ // linear layout to a prefix-sum-of-row-counts layout. Off by default to
38
+ // preserve the original horizontal-scroll behaviour for wide output.
39
+ wrap?: boolean;
22
40
  }
23
41
 
24
42
  let {
@@ -27,8 +45,61 @@
27
45
  lineHeight = 20,
28
46
  class: className = "",
29
47
  highlightLine = null,
48
+ fetchLineHistory,
49
+ endLabel = "end of output",
50
+ endTone = "muted",
51
+ wrap = $bindable(false),
30
52
  }: Props = $props();
31
53
 
54
+ // --- Frame-history inline expansion (single expansion at a time) ---
55
+ // expandedLine is the absolute line number whose history block is open;
56
+ // expandedFrames holds the fetched whole-region frames (null while loading).
57
+ let expandedLine = $state<number | null>(null);
58
+ let expandedFrames = $state<string[][] | null>(null);
59
+ let expandedError = $state(false);
60
+
61
+ const FRAME_BLOCK_PAD = 8; // px padding above+below the block content
62
+ const FRAME_GAP = 6; // px between consecutive frames
63
+ const FRAME_BLOCK_MAX = 400; // px cap; the block scrolls internally beyond this
64
+
65
+ // Height the open history block occupies in the virtual surface. Subsequent
66
+ // lines are shifted down by exactly this, so the math stays a single offset.
67
+ let frameBlockHeight = $derived.by(() => {
68
+ if (expandedLine === null) return 0;
69
+ if (!expandedFrames) return lineHeight * 2; // loading / error placeholder
70
+ let h = FRAME_BLOCK_PAD * 2;
71
+ for (const frame of expandedFrames) {
72
+ h += lineHeight; // per-frame label
73
+ h += frame.length * lineHeight; // the frame's rows
74
+ }
75
+ if (expandedFrames.length > 1) h += FRAME_GAP * (expandedFrames.length - 1);
76
+ return Math.min(h, FRAME_BLOCK_MAX);
77
+ });
78
+
79
+ function collapseHistory() {
80
+ expandedLine = null;
81
+ expandedFrames = null;
82
+ expandedError = false;
83
+ }
84
+
85
+ async function toggleHistory(lineNum: number) {
86
+ if (expandedLine === lineNum) {
87
+ collapseHistory();
88
+ return;
89
+ }
90
+ expandedLine = lineNum;
91
+ expandedFrames = null;
92
+ expandedError = false;
93
+ const fn = fetchLineHistory;
94
+ if (!fn) return;
95
+ try {
96
+ const frames = await fn(lineNum);
97
+ if (expandedLine === lineNum) expandedFrames = frames;
98
+ } catch {
99
+ if (expandedLine === lineNum) expandedError = true;
100
+ }
101
+ }
102
+
32
103
  let flashLine = $state<number | null>(null);
33
104
  let flashTimer: ReturnType<typeof setTimeout> | null = null;
34
105
 
@@ -55,54 +126,229 @@
55
126
 
56
127
  let truncationBannerHeight = $derived(cache.firstAvailableLine > 0 ? lineHeight : 0);
57
128
 
58
- // Convert a global line number to a pixel Y position.
129
+ let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
130
+
131
+ // ---- wrap-mode row accounting ---------------------------------------
132
+ // When wrapping, each line occupies as many `lineHeight` rows as its text
133
+ // wraps into at the current column width. rowCounts remembers the wrapped
134
+ // row count for every line whose text has been observed; lines never loaded
135
+ // (pruned or out-of-window) default to 1 row. prefixSums[i] is the total
136
+ // rows consumed by lines [firstAvailableLine, firstAvailableLine + i), so
137
+ // lineTop / totalHeight / the scroll-position→line lookup stay O(1) or
138
+ // O(log n) even though rows are no longer uniform. A plain Map is
139
+ // deliberate: reactivity is driven by the `rowCountVersion` counter (bumped
140
+ // on any change), not by per-key subscriptions — iterating a SvelteMap of
141
+ // tens of thousands of lines on every streamed line would be wasteful.
142
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
143
+ const rowCounts = new Map<number, number>();
144
+ let rowCountVersion = $state(0);
145
+ let prefixSums = $state<number[]>([]);
146
+
147
+ // Right padding on the text cell (pr-4); reserved when computing how many
148
+ // columns a wrapped line can use.
149
+ const TEXT_PADDING = 16;
150
+
151
+ let availableColumns = $derived(
152
+ wrap && charWidth > 0
153
+ ? Math.max(1, Math.floor((containerWidth - gutterWidth - TEXT_PADDING) / charWidth))
154
+ : 1,
155
+ );
156
+
157
+ function wrappedRowsFor(text: string | undefined): number {
158
+ if (!text || !wrap) return 1;
159
+ const cols = availableColumns;
160
+ if (cols <= 1) return 1;
161
+ return Math.max(1, Math.ceil(visibleColumns(text) / cols));
162
+ }
163
+
164
+ // Line-text white-space model: wrapped lines break anywhere (so a long
165
+ // token wraps mid-word the way a terminal would), unwrapped lines stay on
166
+ // one horizontal-scrolling row.
167
+ let lineTextClass = $derived(wrap ? "break-anywhere whitespace-pre-wrap" : "whitespace-pre");
168
+
169
+ // Recompute row counts for every loaded line whenever wrapping is on and
170
+ // either the cache or the available column width moves. Pruned lines keep
171
+ // their last-computed count so scroll geometry stays stable.
172
+ $effect(() => {
173
+ if (!wrap) return;
174
+ const lines = cache.lines;
175
+ void availableColumns;
176
+ let changed = false;
177
+ for (const [num, text] of lines) {
178
+ const r = wrappedRowsFor(text);
179
+ const prev = rowCounts.get(num);
180
+ if (prev !== r) {
181
+ rowCounts.set(num, r);
182
+ changed = true;
183
+ }
184
+ }
185
+ if (changed) rowCountVersion++;
186
+ });
187
+
188
+ // Rebuild the prefix-sum table when wrap is on and any geometry input
189
+ // moves. Off ⇒ empty (the fixed-height path ignores it).
190
+ $effect(() => {
191
+ const first = cache.firstAvailableLine;
192
+ const total = cache.totalLines;
193
+ void rowCountVersion;
194
+ void availableColumns;
195
+ if (!wrap) {
196
+ prefixSums = [];
197
+ return;
198
+ }
199
+ const count = Math.max(0, total - first);
200
+ const arr = new Array(count + 1);
201
+ arr[0] = 0;
202
+ let acc = 0;
203
+ for (let i = 0; i < count; i++) {
204
+ acc += rowCounts.get(first + i) ?? 1;
205
+ arr[i + 1] = acc;
206
+ }
207
+ prefixSums = arr;
208
+ });
209
+
210
+ // Drop wrap geometry when wrapping turns off so memory doesn't linger.
211
+ $effect(() => {
212
+ if (!wrap) {
213
+ rowCounts.clear();
214
+ }
215
+ });
216
+
217
+ function lineRowCount(lineNum: number): number {
218
+ return wrap ? (rowCounts.get(lineNum) ?? 1) : 1;
219
+ }
220
+
221
+ // Convert a global line number to a pixel Y position. Lines below an open
222
+ // history block are pushed down by the block's height (single expansion, so
223
+ // it's one conditional offset on top of the wrap/non-wrap row math).
59
224
  function lineTop(lineNum: number): number {
60
- return (lineNum - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
225
+ const base =
226
+ truncationBannerHeight +
227
+ (expandedLine !== null && lineNum > expandedLine ? frameBlockHeight : 0);
228
+ if (!wrap) {
229
+ return (lineNum - cache.firstAvailableLine) * lineHeight + base;
230
+ }
231
+ const i = lineNum - cache.firstAvailableLine;
232
+ if (i <= 0) return base;
233
+ if (i >= prefixSums.length) {
234
+ const last = prefixSums.length > 0 ? (prefixSums[prefixSums.length - 1] ?? 0) : 0;
235
+ return last * lineHeight + base;
236
+ }
237
+ return (prefixSums[i] ?? 0) * lineHeight + base;
61
238
  }
62
239
 
63
- let visibleStart = $derived(
64
- Math.max(
65
- cache.firstAvailableLine,
66
- cache.firstAvailableLine +
67
- Math.floor(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight) -
68
- OVERSCAN,
69
- ),
70
- );
71
- let visibleEnd = $derived(
72
- Math.max(
73
- cache.firstAvailableLine,
74
- Math.min(
75
- Math.max(0, cache.totalLines - 1),
76
- cache.firstAvailableLine +
77
- Math.ceil(
78
- Math.max(0, scrollTop - truncationBannerHeight + containerHeight) /
79
- lineHeight,
80
- ) +
240
+ function lineHeightPx(lineNum: number): number {
241
+ return lineRowCount(lineNum) * lineHeight;
242
+ }
243
+
244
+ // Largest i with prefixSums[i] <= yRows (i.e. the line index whose row span
245
+ // covers the given scroll offset, in rows). Used to map a scroll position
246
+ // back to a line number when wrapping makes the layout non-linear.
247
+ function lineIndexAtY(yRows: number): number {
248
+ const arr = prefixSums;
249
+ if (arr.length === 0) return 0;
250
+ if (yRows <= (arr[0] ?? 0)) return 0;
251
+ const last = arr.length - 1;
252
+ if (yRows >= (arr[last] ?? Number.POSITIVE_INFINITY)) return last;
253
+ let lo = 0;
254
+ let hi = last;
255
+ while (lo < hi) {
256
+ const mid = (lo + hi + 1) >> 1;
257
+ if ((arr[mid] ?? Number.POSITIVE_INFINITY) <= yRows) lo = mid;
258
+ else hi = mid - 1;
259
+ }
260
+ return lo;
261
+ }
262
+
263
+ let visibleStart = $derived.by(() => {
264
+ const first = cache.firstAvailableLine;
265
+ if (!wrap) {
266
+ return Math.max(
267
+ first,
268
+ first +
269
+ Math.floor(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight) -
81
270
  OVERSCAN,
82
- ),
83
- ),
84
- );
271
+ );
272
+ }
273
+ const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
274
+ const idx = lineIndexAtY(yRows) - OVERSCAN;
275
+ return Math.max(first, first + Math.max(0, idx));
276
+ });
277
+
278
+ let visibleEnd = $derived.by(() => {
279
+ const first = cache.firstAvailableLine;
280
+ const maxLine = Math.max(0, cache.totalLines - 1);
281
+ if (!wrap) {
282
+ return Math.max(
283
+ first,
284
+ Math.min(
285
+ maxLine,
286
+ first +
287
+ Math.ceil(
288
+ Math.max(0, scrollTop - truncationBannerHeight + containerHeight) /
289
+ lineHeight,
290
+ ) +
291
+ OVERSCAN,
292
+ ),
293
+ );
294
+ }
295
+ const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
296
+ const viewportRows = containerHeight / lineHeight;
297
+ const limit = yRows + viewportRows + OVERSCAN;
298
+ const count = prefixSums.length - 1;
299
+ let end = lineIndexAtY(yRows);
300
+ while (end < count && (prefixSums[end] ?? Number.POSITIVE_INFINITY) < limit) end++;
301
+ return Math.min(maxLine, first + Math.max(0, end));
302
+ });
303
+
304
+ // Live-region overlay rows, rendered in place below the committed lines.
305
+ let overlayRows = $derived(cache.overlayRows);
85
306
 
86
307
  let totalHeight = $derived.by(() => {
87
- const availableLines = cache.totalLines - cache.firstAvailableLine;
88
308
  const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
89
- const linesHeight = availableLines * lineHeight + truncationBannerHeight;
90
- const streamingHeight = !cache.finished && cache.totalLines > 0 ? lineHeight : 0;
309
+ const linesHeight = wrap
310
+ ? (prefixSums.length > 0
311
+ ? (prefixSums[prefixSums.length - 1] ?? 0)
312
+ : Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
313
+ lineHeight +
314
+ truncationBannerHeight
315
+ : (cache.totalLines - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
316
+ const overlayHeight = overlayRows.length * lineHeight;
317
+ // Reserve a row for the streaming cursor only when it stands on its own
318
+ // fresh line. With a live tail it rides the last overlay row (counted in
319
+ // overlayHeight), so no extra row is needed.
320
+ const streamingHeight = isStreaming && overlayRows.length === 0 ? lineHeight : 0;
321
+ // A finished log gets an "end of output" sentinel where the streaming
322
+ // indicator sits while live (mutually exclusive). It's drawn two rows
323
+ // tall so the dashed rule has breathing room above the centred label.
324
+ const sentinelHeight = cache.finished && cache.totalLines > 0 ? lineHeight * 2 : 0;
91
325
  const blankHeight = cache.totalLines > 0 ? BLANK_LINES_AT_END * lineHeight : 0;
92
- return Math.max(linesHeight + streamingHeight + blankHeight, containerHeight);
326
+ return Math.max(
327
+ linesHeight +
328
+ overlayHeight +
329
+ streamingHeight +
330
+ sentinelHeight +
331
+ blankHeight +
332
+ frameBlockHeight,
333
+ containerHeight,
334
+ );
93
335
  });
94
336
 
95
- let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
96
-
97
337
  // Width of the virtual scroll surface: wide enough for the longest line
98
338
  // seen so far, never narrower than the viewport (so short logs show no
99
- // horizontal scrollbar).
339
+ // horizontal scrollbar). Wrapping pins the surface to the viewport — there
340
+ // is no horizontal axis to scroll.
100
341
  let surfaceWidth = $derived(
101
- Math.max(containerWidth, gutterWidth + cache.maxLineColumns * charWidth + SURFACE_PADDING),
342
+ wrap
343
+ ? containerWidth
344
+ : Math.max(
345
+ containerWidth,
346
+ gutterWidth + cache.maxLineColumns * charWidth + SURFACE_PADDING,
347
+ ),
102
348
  );
103
349
 
104
350
  let renderedLines = $derived.by(() => {
105
- const lines: Array<{ num: number; text: string | undefined }> = [];
351
+ const lines: Array<{ num: number; text: string | undefined; frameCount: number }> = [];
106
352
  const start = Math.max(cache.firstAvailableLine, visibleStart);
107
353
  const end = Math.min(cache.totalLines - 1, visibleEnd);
108
354
 
@@ -110,11 +356,15 @@
110
356
  lines.push({
111
357
  num: i,
112
358
  text: cache.lines.get(i),
359
+ frameCount: cache.frameCounts.get(i) ?? 0,
113
360
  });
114
361
  }
115
362
  return lines;
116
363
  });
117
364
 
365
+ // Anchors are clickable only when the parent supplied a history fetcher.
366
+ let canExpand = $derived(fetchLineHistory !== undefined);
367
+
118
368
  // The fetcher serves on-demand scroll-up loads only; the parent seeds
119
369
  // the initial cache via onStream (typically a tail fetch) so the
120
370
  // viewport lands at the end of the log without racing the auto-scroll.
@@ -150,14 +400,16 @@
150
400
  untrack(() => cache.prune(vs, ve));
151
401
  });
152
402
 
153
- let prevTotalLines = 0; // plain variable — intentionally non-reactive
403
+ let prevTailRows = 0; // plain variable — intentionally non-reactive
154
404
 
155
405
  $effect(() => {
156
- const currentTotal = cache.totalLines;
157
- if (currentTotal > prevTotalLines && isAutoScroll && !userScrolledUp) {
406
+ // Track committed lines AND live overlay rows so an animating region at
407
+ // the tail keeps a bottom-anchored viewport pinned to the bottom.
408
+ const currentTail = cache.totalLines + overlayRows.length;
409
+ if (currentTail > prevTailRows && isAutoScroll && !userScrolledUp) {
158
410
  requestAnimationFrame(() => scrollToBottom());
159
411
  }
160
- prevTotalLines = currentTotal;
412
+ prevTailRows = currentTail;
161
413
  });
162
414
 
163
415
  function onScroll(e: Event & { currentTarget: EventTarget & HTMLDivElement }) {
@@ -203,6 +455,13 @@
203
455
  if (containerEl) {
204
456
  containerHeight = containerEl.clientHeight;
205
457
  containerWidth = containerEl.clientWidth;
458
+ // Resync the reactive scroll position with the DOM. The browser
459
+ // clamps scrollTop when the viewport grows (e.g. maximizing the
460
+ // console), but no scroll event fires for that clamp — so without
461
+ // this the virtualizer keeps a stale, too-large scrollTop and
462
+ // renders an empty window until the first manual scroll.
463
+ scrollTop = containerEl.scrollTop;
464
+ if (isAutoScroll && !userScrolledUp) scrollToBottom();
206
465
  }
207
466
  measureCharWidth();
208
467
  }
@@ -231,6 +490,7 @@
231
490
 
232
491
  export function reset() {
233
492
  cache.reset();
493
+ collapseHistory();
234
494
  isAutoScroll = true;
235
495
  userScrolledUp = false;
236
496
  scrollTop = 0;
@@ -277,8 +537,16 @@
277
537
  });
278
538
  </script>
279
539
 
540
+ <!-- One place that renders ANSI-converted markup, so the necessary @html escape
541
+ hatch (the input is sanitised by ansiLineToHtml) lives behind a single
542
+ audited disable rather than being repeated per call site. -->
543
+ {#snippet ansiLine(text: string)}
544
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
545
+ <span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>
546
+ {/snippet}
547
+
280
548
  <div
281
- class="log-console relative flex h-full w-full flex-col overflow-hidden bg-mist-950 font-mono text-sm {className}"
549
+ class="log-console relative flex h-full w-full flex-col overflow-hidden bg-[var(--rw-con-bg)] font-mono text-[12.5px] {className}"
282
550
  >
283
551
  <!-- Hidden ruler: one monospace column is measured from this off-screen
284
552
  sample to size the horizontal scroll surface without per-line reflow. -->
@@ -313,60 +581,172 @@
313
581
  {/if}
314
582
 
315
583
  {#each renderedLines as line (line.num)}
584
+ {@const isAnchor = canExpand && line.frameCount > 0}
316
585
  <div
317
- class="log-line absolute right-0 left-0 flex items-center hover:bg-mist-900/50 {flashLine ===
586
+ class="log-line absolute right-0 left-0 flex {wrap
587
+ ? 'items-start'
588
+ : 'items-center'} hover:bg-[rgb(255_255_255_/_0.025)] {flashLine ===
318
589
  line.num
319
590
  ? 'log-line--flash'
320
591
  : ''}"
321
- style="top: {lineTop(line.num)}px; height: {lineHeight}px;"
592
+ style="top: {lineTop(line.num)}px; height: {lineHeightPx(line.num)}px;"
322
593
  >
323
594
  <div
324
- class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right text-mist-500 select-none"
595
+ class="sticky left-0 z-10 flex flex-shrink-0 items-center justify-end gap-1 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
325
596
  style="width: {gutterWidth}px;"
326
597
  >
598
+ {#if isAnchor}
599
+ <button
600
+ type="button"
601
+ class="frame-toggle {expandedLine === line.num
602
+ ? 'text-aurora-400'
603
+ : 'text-[var(--rw-con-dim)] hover:text-aurora-400'}"
604
+ title="{line.frameCount} earlier frame{line.frameCount === 1
605
+ ? ''
606
+ : 's'} — click to {expandedLine === line.num
607
+ ? 'hide'
608
+ : 'rewind'}"
609
+ aria-label="Toggle frame history for line {line.num + 1}"
610
+ aria-expanded={expandedLine === line.num}
611
+ onclick={() => toggleHistory(line.num)}
612
+ >
613
+ ↻
614
+ </button>
615
+ {/if}
327
616
  {line.num + 1}
328
617
  </div>
329
- <div class="flex-1 pr-4 text-mist-200">
618
+ <div class="min-w-0 flex-1 pr-4 text-[var(--rw-con-text)]">
330
619
  {#if line.text !== undefined}
331
620
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
332
- <span class="whitespace-pre">{@html ansiLineToHtml(line.text)}</span>
621
+ <span class={lineTextClass}>{@html ansiLineToHtml(line.text)}</span>
333
622
  {:else}
334
- <span class="text-mist-600 italic">Loading...</span>
623
+ <span class="text-[var(--rw-con-dim)] italic">Loading...</span>
335
624
  {/if}
336
625
  </div>
337
626
  </div>
338
627
  {/each}
339
628
 
340
- {#if isStreaming && cache.totalLines > 0}
629
+ {#if expandedLine !== null}
630
+ <div
631
+ class="frame-history absolute right-0 left-0 overflow-y-auto border-y border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]"
632
+ style="top: {lineTop(expandedLine) +
633
+ lineHeightPx(expandedLine)}px; height: {frameBlockHeight}px;"
634
+ >
635
+ <div style="padding: {FRAME_BLOCK_PAD}px 0;">
636
+ {#if expandedFrames}
637
+ {#each expandedFrames as frame, fi (fi)}
638
+ <div style="margin-top: {fi > 0 ? FRAME_GAP : 0}px;">
639
+ <div
640
+ class="px-3 text-xs text-[var(--rw-con-dim)] select-none"
641
+ style="height: {lineHeight}px; line-height: {lineHeight}px;"
642
+ >
643
+ Frame {fi + 1} of {expandedFrames.length}
644
+ </div>
645
+ {#each frame as row, ri (ri)}
646
+ <div
647
+ class="flex items-center"
648
+ style="height: {lineHeight}px;"
649
+ >
650
+ <div
651
+ class="flex-shrink-0 pr-3 text-right text-[var(--rw-con-gutter)] select-none"
652
+ style="width: {gutterWidth}px;"
653
+ ></div>
654
+ <div class="flex-1 pr-4 text-[var(--rw-con-text)]">
655
+ {@render ansiLine(row)}
656
+ </div>
657
+ </div>
658
+ {/each}
659
+ </div>
660
+ {/each}
661
+ {:else}
662
+ <div
663
+ class="px-3 text-xs text-[var(--rw-con-dim)] italic"
664
+ style="line-height: {lineHeight}px; padding-left: {gutterWidth}px;"
665
+ >
666
+ {expandedError ? "Failed to load frame history" : "Loading frames…"}
667
+ </div>
668
+ {/if}
669
+ </div>
670
+ </div>
671
+ {/if}
672
+
673
+ {#each overlayRows as row, i (i)}
674
+ <div
675
+ class="log-line log-line--live absolute right-0 left-0 flex items-center"
676
+ style="top: {lineTop(cache.totalLines + i)}px; height: {lineHeight}px;"
677
+ >
678
+ <div
679
+ class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
680
+ style="width: {gutterWidth}px;"
681
+ >
682
+ ⋯
683
+ </div>
684
+ <!-- The cursor rides the end of the last live overlay row so a
685
+ still-being-appended line shows the caret inline, the way a
686
+ terminal parks it before the next byte arrives. -->
687
+ <div class="flex-1 pr-4 text-[var(--rw-con-text)]">
688
+ {@render ansiLine(
689
+ row,
690
+ )}{#if !cache.finished && i === overlayRows.length - 1}<span
691
+ class="stream-cursor"
692
+ aria-hidden="true"
693
+ ></span>{/if}
694
+ </div>
695
+ </div>
696
+ {/each}
697
+
698
+ <!-- Streaming cursor on a fresh line: shown only when the last
699
+ committed line ended with a newline (no live tail). When output
700
+ is still being appended mid-line, the cursor rides the end of the
701
+ last overlay row instead (see the overlay loop above). -->
702
+ {#if isStreaming && overlayRows.length === 0}
341
703
  <div
342
704
  class="streaming-indicator absolute right-0 left-0 flex items-center"
343
705
  style="top: {lineTop(cache.totalLines)}px; height: {lineHeight}px;"
344
706
  >
345
707
  <div
346
- class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right select-none"
708
+ class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
347
709
  style="width: {gutterWidth}px;"
348
- ></div>
349
- <div
350
- class="sticky flex items-center gap-1 text-mist-400"
351
- style="left: {gutterWidth}px;"
352
710
  >
353
- <span class="dot" style="animation-delay: 0ms;"></span>
354
- <span class="dot" style="animation-delay: 150ms;"></span>
355
- <span class="dot" style="animation-delay: 300ms;"></span>
711
+ {cache.totalLines + 1}
712
+ </div>
713
+ <div class="flex-1 pr-4">
714
+ <span class="stream-cursor" aria-hidden="true"></span>
356
715
  </div>
357
716
  </div>
358
717
  {/if}
359
718
 
719
+ {#if cache.finished && cache.totalLines > 0}
720
+ <div
721
+ class="sentinel absolute right-4 left-4 flex justify-center select-none"
722
+ style="top: {lineTop(cache.totalLines)}px; height: {lineHeight * 2}px;"
723
+ >
724
+ <span
725
+ class="mt-[14px] border-t border-dashed pt-3 text-center text-[11px] tracking-wide {endTone ===
726
+ 'warn'
727
+ ? 'sentinel--warn'
728
+ : 'sentinel--muted'}"
729
+ >
730
+ ── {endLabel} ──
731
+ </span>
732
+ </div>
733
+ {/if}
734
+
360
735
  {#if cache.totalLines > 0}
361
736
  {#each Array.from({ length: BLANK_LINES_AT_END }, (_, i) => i) as i (i)}
362
737
  <div
363
738
  class="absolute right-0 left-0 flex items-center"
364
739
  style="top: {lineTop(
365
- cache.totalLines + (isStreaming ? 1 : 0) + i,
740
+ cache.finished
741
+ ? cache.totalLines + 2 + i
742
+ : cache.totalLines +
743
+ overlayRows.length +
744
+ (isStreaming && overlayRows.length === 0 ? 1 : 0) +
745
+ i,
366
746
  )}px; height: {lineHeight}px;"
367
747
  >
368
748
  <div
369
- class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right text-mist-700 opacity-0 select-none"
749
+ class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right opacity-0 select-none"
370
750
  style="width: {gutterWidth}px;"
371
751
  >
372
752
  ~
@@ -375,17 +755,21 @@
375
755
  {/each}
376
756
  {/if}
377
757
 
378
- {#if cache.totalLines === 0 && !fetcher?.isFetching}
379
- <div class="absolute inset-0 flex items-center justify-center text-mist-500">
758
+ {#if cache.totalLines === 0 && overlayRows.length === 0 && !fetcher?.isFetching}
759
+ <div
760
+ class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
761
+ >
380
762
  <div class="text-center">
381
763
  <div class="mb-1 text-lg">No output yet</div>
382
- <div class="text-sm text-mist-600">Waiting for logs...</div>
764
+ <div class="text-sm text-[var(--rw-con-gutter)]">Waiting for logs...</div>
383
765
  </div>
384
766
  </div>
385
767
  {/if}
386
768
 
387
769
  {#if cache.totalLines === 0 && fetcher?.isFetching}
388
- <div class="absolute inset-0 flex items-center justify-center text-mist-500">
770
+ <div
771
+ class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
772
+ >
389
773
  <div class="flex items-center gap-2">
390
774
  <span class="dot" style="animation-delay: 0ms;"></span>
391
775
  <span class="dot" style="animation-delay: 150ms;"></span>
@@ -400,11 +784,11 @@
400
784
  {#if userScrolledUp && cache.totalLines > 0}
401
785
  <button
402
786
  class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-full border
403
- border-mist-700 bg-mist-800
787
+ border-[rgb(255_255_255_/_0.1)] bg-[var(--rw-con-panel)]
404
788
  px-3 py-1.5 text-xs
405
- font-medium text-mist-200 shadow-lg
789
+ font-medium text-[var(--rw-con-text)] shadow-lg
406
790
  transition-all duration-200 hover:scale-105
407
- hover:bg-mist-700 active:scale-95"
791
+ hover:bg-[rgb(255_255_255_/_0.06)] active:scale-95"
408
792
  onclick={enableAutoScroll}
409
793
  >
410
794
  <svg
@@ -421,20 +805,20 @@
421
805
  {/if}
422
806
 
423
807
  <div
424
- class="flex flex-shrink-0 items-center justify-between border-t border-mist-800
425
- bg-mist-900/80 px-3 py-1.5 text-xs text-mist-400"
808
+ class="flex flex-shrink-0 items-center justify-between border-t border-[rgb(255_255_255_/_0.06)]
809
+ bg-[var(--rw-con-panel)] px-3.5 py-2 text-[11px] text-[var(--rw-con-dim)]"
426
810
  >
427
811
  <div class="flex items-center gap-3">
428
812
  {#if isStreaming}
429
- <div class="flex items-center gap-1.5 text-info">
813
+ <div class="flex items-center gap-1.5 text-aurora-400">
430
814
  <div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
431
815
  Streaming
432
816
  </div>
433
817
  {:else if cache.finished}
434
- <span class="text-mist-500">Stream ended</span>
818
+ <span>Stream ended</span>
435
819
  {/if}
436
820
  {#if fetcher?.isFetching}
437
- <span class="text-mist-500">Fetching...</span>
821
+ <span>Fetching...</span>
438
822
  {/if}
439
823
  </div>
440
824
  <div class="flex items-center gap-4">
@@ -443,7 +827,21 @@
443
827
  <span>{formatBytes(cache.totalBytes)}</span>
444
828
  {/if}
445
829
  {#if isAutoScroll}
446
- <span class="text-info">Auto-scroll</span>
830
+ <span class="inline-flex items-center gap-1.5 text-[#7fe0b0]">
831
+ <svg
832
+ class="h-3 w-3"
833
+ viewBox="0 0 24 24"
834
+ fill="none"
835
+ stroke="currentColor"
836
+ stroke-width="2.2"
837
+ stroke-linecap="round"
838
+ stroke-linejoin="round"
839
+ >
840
+ <line x1="12" y1="5" x2="12" y2="19" />
841
+ <polyline points="6 13 12 19 18 13" />
842
+ </svg>
843
+ Auto-scroll
844
+ </span>
447
845
  {/if}
448
846
  </div>
449
847
  </div>
@@ -455,10 +853,31 @@
455
853
  tab-size: 8;
456
854
  }
457
855
 
856
+ /* End-of-output marker: a centred label sitting under a dashed rule, the
857
+ way the approved design closes a finished capture. */
858
+ .sentinel--muted {
859
+ color: var(--rw-con-gutter);
860
+ border-top-color: rgb(255 255 255 / 0.08);
861
+ }
862
+ .sentinel--warn {
863
+ color: #e5a552;
864
+ border-top-color: rgb(229 165 82 / 0.3);
865
+ }
866
+
458
867
  .log-line--flash {
459
868
  animation: log-line-flash 1.5s ease-out;
460
869
  }
461
870
 
871
+ .frame-toggle {
872
+ cursor: pointer;
873
+ font-size: 0.85em;
874
+ line-height: 1;
875
+ background: transparent;
876
+ border: none;
877
+ padding: 0;
878
+ transition: color 120ms ease;
879
+ }
880
+
462
881
  @keyframes log-line-flash {
463
882
  0% {
464
883
  background-color: oklch(0.8 0.18 70 / 0.45);
@@ -468,6 +887,37 @@
468
887
  }
469
888
  }
470
889
 
890
+ /* Blinking teal block caret shown while a run is actively streaming. One
891
+ monospace cell wide so it reads as a terminal cursor parked at the write
892
+ position — inline after a still-appending line, or alone on the next. */
893
+ .stream-cursor {
894
+ display: inline-block;
895
+ width: 1ch;
896
+ height: 1.1em;
897
+ vertical-align: text-bottom;
898
+ background-color: var(--color-aurora-400);
899
+ animation: stream-cursor-blink 1.05s steps(2, start) infinite;
900
+ }
901
+
902
+ @keyframes stream-cursor-blink {
903
+ 0%,
904
+ 50% {
905
+ opacity: 1;
906
+ }
907
+ 50.01%,
908
+ 100% {
909
+ opacity: 0;
910
+ }
911
+ }
912
+
913
+ /* Reduced motion: keep the caret solid (still a clear "live" marker) rather
914
+ than blinking. */
915
+ @media (prefers-reduced-motion: reduce) {
916
+ .stream-cursor {
917
+ animation: none;
918
+ }
919
+ }
920
+
471
921
  .dot {
472
922
  display: inline-block;
473
923
  width: 4px;