<script lang="ts">
import {
Activity,
Check,
ChevronDown,
ChevronRight,
CircleAlert,
CircleCheck,
CircleSlash,
Clock,
Copy,
Terminal,
TriangleAlert,
} from "lucide-svelte";
import { onDestroy } from "svelte";
import type { CellExecutionItem } from "../lib/streaming-v2";
export let item: CellExecutionItem;
export let expanded: boolean = true;
export let maxRenderedLines: number = 400;
/**
* Which of this cell's streams are expanded. It is owned by the transcript rather than by this
* component because a virtualized row is unmounted as soon as it scrolls out: local state would
* silently collapse a 5000-line output the moment the reader looked away and back.
*/
export let streamState: Record<number, boolean> = {};
export let onToggleStream: (index: number) => void = () => {};
let copied = false;
let copyTimeout: ReturnType<typeof setTimeout> | undefined;
/**
* A running cell carries its own clock. The interval exists only while this cell is running, so a
* transcript with a thousand finished cells holds one timer, not a thousand.
*/
let now = Date.now();
let tick: ReturnType<typeof setInterval> | undefined;
function stopTick() { if (tick !== undefined) { clearInterval(tick); tick = undefined; } }
$: if (item.status === "running") {
if (tick === undefined) tick = setInterval(() => { now = Date.now(); }, 500);
} else {
stopTick();
}
onDestroy(stopTick);
$: liveMs = item.status === "running" ? Math.max(0, now - item.created_ms) : undefined;
let codeCopied = false;
let codeCopyTimeout: ReturnType<typeof setTimeout> | undefined;
function toggle() {
expanded = !expanded;
}
function toggleStreamExpand(idx: number) {
onToggleStream(idx);
}
async function copyOutput(e?: MouseEvent) {
if (e) e.stopPropagation();
const text = item.outputs.map((o) => `[${o.stream}] ${o.text}`).join("\n");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
copied = true;
clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => {
copied = false;
}, 1800);
} catch {
// clipboard error fallback
}
}
async function copyCode(e?: MouseEvent) {
if (e) e.stopPropagation();
if (!item.command) return;
try {
await navigator.clipboard.writeText(item.command);
codeCopied = true;
clearTimeout(codeCopyTimeout);
codeCopyTimeout = setTimeout(() => {
codeCopied = false;
}, 1800);
} catch {
// clipboard error fallback
}
}
function formatDuration(ms?: number): string {
if (!ms) return "";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
/** A live clock always says something, and keeps one decimal so the number does not jump in width. */
function formatElapsed(ms: number): string {
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
return `${m}m${String(s).padStart(2, "0")}s`;
}
function sliceOutputText(text: string, maxLines: number, isExpanded: boolean): { text: string; truncatedLocal: boolean; visibleCount: number; totalCount: number } {
const lines = text.split("\n");
const totalCount = lines.length;
if (isExpanded || totalCount <= maxLines) {
return { text, truncatedLocal: false, visibleCount: totalCount, totalCount };
}
const visible = lines.slice(-maxLines).join("\n");
return { text: visible, truncatedLocal: true, visibleCount: maxLines, totalCount };
}
$: renderedOutputs = item.outputs.map((out, idx) => ({
out,
idx,
// streamState[idx] is only true once the operator asked for the whole stream. Passing its
// negation here meant every stream counted as expanded and nothing was ever truncated: the
// sliver it renders, at 400 lines or 5000, went straight into the DOM.
sliced: sliceOutputText(out.text, maxRenderedLines, Boolean(streamState[idx])),
}));
</script>
<div class="cell-terminal">
<div class="terminal-header">
<div class="header-left">
<Terminal size={13} stroke-width={1.5} class="lane-cell" />
{#if item.status === "running"}
<span class="chip chip-agent">
<Activity size={13} stroke-width={1.5} />
<span>running</span>
{#if liveMs !== undefined}
<span class="num">{formatElapsed(liveMs)}</span>
{/if}
</span>
{:else if item.status === "ok"}
<span class="chip chip-ok"><CircleCheck size={13} stroke-width={1.5} /> ok</span>
{:else if item.status === "error"}
<span class="chip chip-err"><CircleAlert size={13} stroke-width={1.5} /> error</span>
{:else if item.status === "yielded"}
<span class="chip chip-warn"><Clock size={13} stroke-width={1.5} /> yielded{#if item.duration_ms} ({formatDuration(item.duration_ms)}){/if}</span>
{:else if item.status === "interrupted"}
<span class="chip chip-warn"><CircleSlash size={13} stroke-width={1.5} /> interrupted</span>
{:else}
<span class="chip">{String(item.status).replace(/_/g, " ")}</span>
{/if}
{#if item.command}
<span class="mute cmd-preview" title={item.command}>{item.command}</span>
{/if}
{#if item.exit_code !== undefined && item.exit_code !== null}
<span class="num" class:err-t={item.exit_code !== 0} class:mute={item.exit_code === 0}>exit {item.exit_code}</span>
{/if}
{#if item.duration_ms && item.status !== "yielded"}
<span class="mute num">{formatDuration(item.duration_ms)}</span>
{/if}
{#if item.truncated}
<span class="chip chip-warn"><TriangleAlert size={13} stroke-width={1.5} /> truncated</span>
{/if}
</div>
<div class="header-right">
<button
type="button"
class="btn btn-icon disclosure cell-toggle"
class:open={expanded}
onclick={toggle}
aria-expanded={expanded}
title={expanded ? "collapse cell output" : "expand cell output"}
aria-label={expanded ? "collapse cell output" : "expand cell output"}
>
{#if expanded}
<ChevronDown size={13} stroke-width={1.5} />
{:else}
<ChevronRight size={13} stroke-width={1.5} />
{/if}
</button>
<button
class="btn btn-icon copy-btn"
class:is-copied={copied}
title={copied ? "copied" : "copy cell output"}
aria-label={copied ? "copied" : "copy cell output"}
type="button"
onclick={copyOutput}
>
{#if copied}
<Check size={13} stroke-width={1.5} />
{:else}
<Copy size={13} stroke-width={1.5} />
{/if}
</button>
</div>
</div>
{#if expanded}
<div class="terminal-body">
{#if item.truncated}
<div class="warn">stream truncated by daemon buffer limit</div>
{/if}
{#if item.command}
<section class="block">
<header class="block-head">
<span class="block-label">code</span>
<span class="block-rule"></span>
<button
class="btn btn-icon copy-btn"
class:is-copied={codeCopied}
title={codeCopied ? "copied" : "copy cell code"}
aria-label={codeCopied ? "copied" : "copy cell code"}
type="button"
onclick={copyCode}
>
{#if codeCopied}
<Check size={13} stroke-width={1.5} />
{:else}
<Copy size={13} stroke-width={1.5} />
{/if}
</button>
</header>
<pre class="cell-code">{item.command}</pre>
</section>
{/if}
{#if item.outputs.length === 0}
<div class="mute empty-output">
{#if item.status === "running"}
awaiting output...
{:else}
empty
{/if}
</div>
{:else}
<div class="output-stream-list">
{#each renderedOutputs as { out, idx, sliced } (idx)}
<section class="block">
<header class="block-head">
<span class="block-label">{out.stream}</span>
<span class="block-rule"></span>
{#if sliced.truncatedLocal}
<div class="stream-limit">
<span class="mute num">{sliced.visibleCount} / {sliced.totalCount} lines</span>
<button
type="button"
class="btn btn-icon"
onclick={() => toggleStreamExpand(idx)}
title="expand stream"
aria-label="expand stream"
>
<ChevronRight size={13} stroke-width={1.5} />
</button>
</div>
{:else if streamState[idx] && sliced.totalCount > maxRenderedLines}
<div class="stream-limit">
<span class="mute num">{sliced.totalCount} lines</span>
<button
type="button"
class="btn btn-icon"
onclick={() => toggleStreamExpand(idx)}
title="collapse stream"
aria-label="collapse stream"
>
<ChevronDown size={13} stroke-width={1.5} />
</button>
</div>
{/if}
</header>
<pre class="stream-text" class:err-t={out.stream === "stderr"}>{sliced.text}</pre>
</section>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.cell-terminal {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin: var(--s-2) 0;
width: 100%;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
min-height: 24px;
}
.header-left {
display: flex;
align-items: center;
gap: var(--s-2);
min-width: 0;
flex-wrap: wrap;
}
.cmd-preview {
font-size: var(--fs-2);
font-family: var(--mono);
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-right {
display: flex;
align-items: center;
gap: var(--s-2);
white-space: nowrap;
flex-shrink: 0;
}
.copy-btn.is-copied {
color: var(--ok);
border-color: var(--ok);
}
/* no card, no rail: a cell is a header and the text it produced, indented under one hairline. */
.terminal-body {
margin-left: var(--s-5);
padding: var(--s-2) var(--s-4) var(--s-5) var(--s-5);
border-left: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: var(--s-4);
font-family: var(--mono);
font-size: var(--fs-2);
line-height: var(--lh);
max-height: 480px;
overflow-y: auto;
}
/* one section per thing the cell produced: a named header, then the text. the rule turns the label
into a header for what follows instead of a chip floating above a gap. */
.block { display: flex; flex-direction: column; gap: var(--s-2); }
/* Every rule ends at the same place. The action slot is reserved in EVERY header, because the code
header's copy button is invisible at rest and an element that is only sometimes there cannot be
allowed to shorten the line. */
.block-head {
position: relative;
display: flex;
align-items: center;
gap: var(--s-3);
min-height: 18px;
padding-right: 30px;
}
/* anything at the right end lives in the reserved strip rather than in the flow */
.block-head > .copy-btn,
.block-head > .stream-limit {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
/* the line-count readout is wider than the slot; it masks the end of the rule rather than moving it */
.block-head > .stream-limit { background: var(--bg); padding-left: var(--s-3); }
.block-label {
display: inline-flex;
align-items: center;
gap: var(--s-2);
flex: 0 0 auto;
font-family: var(--sans);
font-size: var(--fs-1);
font-weight: 500;
letter-spacing: 0.02em;
color: var(--fg-mute);
}
/* fainter than the cell's own rail: the label is the header, the rule only ties it to the text */
.block-rule { flex: 1 1 auto; height: 1px; background: color-mix(in srgb, var(--line) 55%, transparent); }
.empty-output {
padding: var(--s-1) 0;
}
.output-stream-list { display: flex; flex-direction: column; gap: var(--s-4); }
.stream-limit {
display: flex;
align-items: center;
gap: var(--s-3);
}
.stream-text {
margin: 0;
font-family: var(--mono);
font-size: var(--fs-2);
line-height: var(--lh);
color: var(--fg);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
/* the cell's program: what was run, above what came back. the lane rail is the only ornament. */
/* the program sits on the same text edge as its output. the header says which is which; a tint
and an extra inset only pushed the code 8px to the right of the text it belongs with. */
.cell-code {
margin: 0;
font-family: var(--mono);
font-size: var(--fs-2);
line-height: var(--lh);
color: var(--fg);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
tab-size: 4;
}
/* an expanded cell needs no visible collapse affordance; a collapsed one cannot hide its own way in. */
.cell-toggle.open { opacity: 0; }
.cell-terminal:hover .cell-toggle.open,
.cell-toggle.open:focus-visible { opacity: 1; }
</style>