@runwisp/ui 0.9.0 → 0.11.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,66 +45,310 @@
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
 
35
106
  const cache = new LogCache();
36
107
  let fetcher: LogFetcher | null = $state(null);
37
108
  let containerEl: HTMLDivElement | null = $state(null);
109
+ let rulerEl: HTMLSpanElement | null = $state(null);
38
110
  let scrollTop = $state(0);
39
111
  let containerHeight = $state(0);
112
+ let containerWidth = $state(0);
113
+ // Monospace character width in px, measured from a hidden ruler so the
114
+ // horizontal scroll surface is sized without per-line DOM reflow.
115
+ let charWidth = $state(8);
40
116
  let isAutoScroll = $state(true);
41
117
  let userScrolledUp = $state(false);
42
118
  let isStreaming = $derived(!cache.finished && cache.totalLines > 0);
43
119
 
44
120
  const OVERSCAN = 50;
45
121
  const BLANK_LINES_AT_END = 3;
122
+ const RULER_SAMPLE = "0".repeat(50);
123
+ // Trailing slack past the last column so the widest line never sits flush
124
+ // against the scroll edge.
125
+ const SURFACE_PADDING = 16;
46
126
 
47
127
  let truncationBannerHeight = $derived(cache.firstAvailableLine > 0 ? lineHeight : 0);
48
128
 
49
- // 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).
50
224
  function lineTop(lineNum: number): number {
51
- 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;
52
238
  }
53
239
 
54
- let visibleStart = $derived(
55
- Math.max(
56
- cache.firstAvailableLine,
57
- cache.firstAvailableLine +
58
- Math.floor(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight) -
59
- OVERSCAN,
60
- ),
61
- );
62
- let visibleEnd = $derived(
63
- Math.max(
64
- cache.firstAvailableLine,
65
- Math.min(
66
- Math.max(0, cache.totalLines - 1),
67
- cache.firstAvailableLine +
68
- Math.ceil(
69
- Math.max(0, scrollTop - truncationBannerHeight + containerHeight) /
70
- lineHeight,
71
- ) +
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) -
72
270
  OVERSCAN,
73
- ),
74
- ),
75
- );
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);
76
306
 
77
307
  let totalHeight = $derived.by(() => {
78
- const availableLines = cache.totalLines - cache.firstAvailableLine;
79
308
  const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
80
- const linesHeight = availableLines * lineHeight + truncationBannerHeight;
81
- 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;
82
325
  const blankHeight = cache.totalLines > 0 ? BLANK_LINES_AT_END * lineHeight : 0;
83
- 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
+ );
84
335
  });
85
336
 
86
- let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
337
+ // Width of the virtual scroll surface: wide enough for the longest line
338
+ // seen so far, never narrower than the viewport (so short logs show no
339
+ // horizontal scrollbar). Wrapping pins the surface to the viewport — there
340
+ // is no horizontal axis to scroll.
341
+ let surfaceWidth = $derived(
342
+ wrap
343
+ ? containerWidth
344
+ : Math.max(
345
+ containerWidth,
346
+ gutterWidth + cache.maxLineColumns * charWidth + SURFACE_PADDING,
347
+ ),
348
+ );
87
349
 
88
350
  let renderedLines = $derived.by(() => {
89
- const lines: Array<{ num: number; text: string | undefined }> = [];
351
+ const lines: Array<{ num: number; text: string | undefined; frameCount: number }> = [];
90
352
  const start = Math.max(cache.firstAvailableLine, visibleStart);
91
353
  const end = Math.min(cache.totalLines - 1, visibleEnd);
92
354
 
@@ -94,11 +356,15 @@
94
356
  lines.push({
95
357
  num: i,
96
358
  text: cache.lines.get(i),
359
+ frameCount: cache.frameCounts.get(i) ?? 0,
97
360
  });
98
361
  }
99
362
  return lines;
100
363
  });
101
364
 
365
+ // Anchors are clickable only when the parent supplied a history fetcher.
366
+ let canExpand = $derived(fetchLineHistory !== undefined);
367
+
102
368
  // The fetcher serves on-demand scroll-up loads only; the parent seeds
103
369
  // the initial cache via onStream (typically a tail fetch) so the
104
370
  // viewport lands at the end of the log without racing the auto-scroll.
@@ -134,14 +400,16 @@
134
400
  untrack(() => cache.prune(vs, ve));
135
401
  });
136
402
 
137
- let prevTotalLines = 0; // plain variable — intentionally non-reactive
403
+ let prevTailRows = 0; // plain variable — intentionally non-reactive
138
404
 
139
405
  $effect(() => {
140
- const currentTotal = cache.totalLines;
141
- 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) {
142
410
  requestAnimationFrame(() => scrollToBottom());
143
411
  }
144
- prevTotalLines = currentTotal;
412
+ prevTailRows = currentTail;
145
413
  });
146
414
 
147
415
  function onScroll(e: Event & { currentTarget: EventTarget & HTMLDivElement }) {
@@ -186,6 +454,24 @@
186
454
  function onResize() {
187
455
  if (containerEl) {
188
456
  containerHeight = containerEl.clientHeight;
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();
465
+ }
466
+ measureCharWidth();
467
+ }
468
+
469
+ // Measure one monospace column from the hidden ruler. Font, zoom, and
470
+ // DPI changes all surface through the ResizeObserver, so this stays
471
+ // accurate without polling.
472
+ function measureCharWidth() {
473
+ if (rulerEl && rulerEl.offsetWidth > 0) {
474
+ charWidth = rulerEl.offsetWidth / RULER_SAMPLE.length;
189
475
  }
190
476
  }
191
477
 
@@ -204,6 +490,7 @@
204
490
 
205
491
  export function reset() {
206
492
  cache.reset();
493
+ collapseHistory();
207
494
  isAutoScroll = true;
208
495
  userScrolledUp = false;
209
496
  scrollTop = 0;
@@ -236,6 +523,8 @@
236
523
  $effect(() => {
237
524
  if (!containerEl) return;
238
525
  containerHeight = containerEl.clientHeight;
526
+ containerWidth = containerEl.clientWidth;
527
+ measureCharWidth();
239
528
 
240
529
  const resizeObserver = new ResizeObserver(() => {
241
530
  onResize();
@@ -248,21 +537,41 @@
248
537
  });
249
538
  </script>
250
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
+
251
548
  <div
252
- 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}"
253
550
  >
551
+ <!-- Hidden ruler: one monospace column is measured from this off-screen
552
+ sample to size the horizontal scroll surface without per-line reflow. -->
553
+ <span
554
+ bind:this={rulerEl}
555
+ aria-hidden="true"
556
+ class="pointer-events-none absolute -top-[9999px] left-0 whitespace-pre select-none"
557
+ >{RULER_SAMPLE}</span
558
+ >
559
+
254
560
  <div bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
255
- <div class="relative" style="height: {totalHeight}px; min-height: 100%;">
561
+ <div
562
+ class="relative"
563
+ style="height: {totalHeight}px; min-height: 100%; width: {surfaceWidth}px; min-width: 100%;"
564
+ >
256
565
  {#if cache.firstAvailableLine > 0}
257
566
  <div
258
567
  class="absolute right-0 left-0 flex items-center bg-warning-700/20 text-warning-400"
259
568
  style="top: 0px; height: {lineHeight}px;"
260
569
  >
261
570
  <div
262
- class="flex-shrink-0 pr-3 text-right select-none"
571
+ class="sticky left-0 z-10 flex-shrink-0 pr-3 text-right select-none"
263
572
  style="width: {gutterWidth}px;"
264
573
  ></div>
265
- <div class="flex-1 truncate pr-4 text-xs">
574
+ <div class="sticky flex-shrink-0 pr-4 text-xs" style="left: {gutterWidth}px;">
266
575
  Log truncated: {cache.firstAvailableLine.toLocaleString()} earlier line{cache.firstAvailableLine ===
267
576
  1
268
577
  ? ""
@@ -272,57 +581,172 @@
272
581
  {/if}
273
582
 
274
583
  {#each renderedLines as line (line.num)}
584
+ {@const isAnchor = canExpand && line.frameCount > 0}
275
585
  <div
276
- 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 ===
277
589
  line.num
278
590
  ? 'log-line--flash'
279
591
  : ''}"
280
- style="top: {lineTop(line.num)}px; height: {lineHeight}px;"
592
+ style="top: {lineTop(line.num)}px; height: {lineHeightPx(line.num)}px;"
281
593
  >
282
594
  <div
283
- class="flex-shrink-0 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"
284
596
  style="width: {gutterWidth}px;"
285
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}
286
616
  {line.num + 1}
287
617
  </div>
288
- <div class="flex-1 truncate pr-4 text-mist-200">
618
+ <div class="min-w-0 flex-1 pr-4 text-[var(--rw-con-text)]">
289
619
  {#if line.text !== undefined}
290
620
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
291
- <span class="whitespace-pre">{@html ansiLineToHtml(line.text)}</span>
621
+ <span class={lineTextClass}>{@html ansiLineToHtml(line.text)}</span>
292
622
  {:else}
293
- <span class="text-mist-600 italic">Loading...</span>
623
+ <span class="text-[var(--rw-con-dim)] italic">Loading...</span>
294
624
  {/if}
295
625
  </div>
296
626
  </div>
297
627
  {/each}
298
628
 
299
- {#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}
300
703
  <div
301
704
  class="streaming-indicator absolute right-0 left-0 flex items-center"
302
705
  style="top: {lineTop(cache.totalLines)}px; height: {lineHeight}px;"
303
706
  >
304
707
  <div
305
- class="flex-shrink-0 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"
306
709
  style="width: {gutterWidth}px;"
307
- ></div>
308
- <div class="flex items-center gap-1 text-mist-400">
309
- <span class="dot" style="animation-delay: 0ms;"></span>
310
- <span class="dot" style="animation-delay: 150ms;"></span>
311
- <span class="dot" style="animation-delay: 300ms;"></span>
710
+ >
711
+ {cache.totalLines + 1}
712
+ </div>
713
+ <div class="flex-1 pr-4">
714
+ <span class="stream-cursor" aria-hidden="true"></span>
312
715
  </div>
313
716
  </div>
314
717
  {/if}
315
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
+
316
735
  {#if cache.totalLines > 0}
317
736
  {#each Array.from({ length: BLANK_LINES_AT_END }, (_, i) => i) as i (i)}
318
737
  <div
319
738
  class="absolute right-0 left-0 flex items-center"
320
739
  style="top: {lineTop(
321
- 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,
322
746
  )}px; height: {lineHeight}px;"
323
747
  >
324
748
  <div
325
- class="flex-shrink-0 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"
326
750
  style="width: {gutterWidth}px;"
327
751
  >
328
752
  ~
@@ -331,17 +755,21 @@
331
755
  {/each}
332
756
  {/if}
333
757
 
334
- {#if cache.totalLines === 0 && !fetcher?.isFetching}
335
- <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
+ >
336
762
  <div class="text-center">
337
763
  <div class="mb-1 text-lg">No output yet</div>
338
- <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>
339
765
  </div>
340
766
  </div>
341
767
  {/if}
342
768
 
343
769
  {#if cache.totalLines === 0 && fetcher?.isFetching}
344
- <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
+ >
345
773
  <div class="flex items-center gap-2">
346
774
  <span class="dot" style="animation-delay: 0ms;"></span>
347
775
  <span class="dot" style="animation-delay: 150ms;"></span>
@@ -356,11 +784,11 @@
356
784
  {#if userScrolledUp && cache.totalLines > 0}
357
785
  <button
358
786
  class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-full border
359
- border-mist-700 bg-mist-800
787
+ border-[rgb(255_255_255_/_0.1)] bg-[var(--rw-con-panel)]
360
788
  px-3 py-1.5 text-xs
361
- font-medium text-mist-200 shadow-lg
789
+ font-medium text-[var(--rw-con-text)] shadow-lg
362
790
  transition-all duration-200 hover:scale-105
363
- hover:bg-mist-700 active:scale-95"
791
+ hover:bg-[rgb(255_255_255_/_0.06)] active:scale-95"
364
792
  onclick={enableAutoScroll}
365
793
  >
366
794
  <svg
@@ -377,20 +805,20 @@
377
805
  {/if}
378
806
 
379
807
  <div
380
- class="flex flex-shrink-0 items-center justify-between border-t border-mist-800
381
- 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)]"
382
810
  >
383
811
  <div class="flex items-center gap-3">
384
812
  {#if isStreaming}
385
- <div class="flex items-center gap-1.5 text-info">
813
+ <div class="flex items-center gap-1.5 text-aurora-400">
386
814
  <div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
387
815
  Streaming
388
816
  </div>
389
817
  {:else if cache.finished}
390
- <span class="text-mist-500">Stream ended</span>
818
+ <span>Stream ended</span>
391
819
  {/if}
392
820
  {#if fetcher?.isFetching}
393
- <span class="text-mist-500">Fetching...</span>
821
+ <span>Fetching...</span>
394
822
  {/if}
395
823
  </div>
396
824
  <div class="flex items-center gap-4">
@@ -399,7 +827,21 @@
399
827
  <span>{formatBytes(cache.totalBytes)}</span>
400
828
  {/if}
401
829
  {#if isAutoScroll}
402
- <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>
403
845
  {/if}
404
846
  </div>
405
847
  </div>
@@ -408,12 +850,34 @@
408
850
  <style>
409
851
  .log-console {
410
852
  --log-line-height: 20px;
853
+ tab-size: 8;
854
+ }
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);
411
865
  }
412
866
 
413
867
  .log-line--flash {
414
868
  animation: log-line-flash 1.5s ease-out;
415
869
  }
416
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
+
417
881
  @keyframes log-line-flash {
418
882
  0% {
419
883
  background-color: oklch(0.8 0.18 70 / 0.45);
@@ -423,6 +887,37 @@
423
887
  }
424
888
  }
425
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
+
426
921
  .dot {
427
922
  display: inline-block;
428
923
  width: 4px;