import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; import { useTerminalTheme } from "../hooks/useTerminalTheme"; import { useStreamingSession } from "../hooks/useStreamingSession"; import type { StreamingSessionService } from "../services/streaming-session-service"; function formatElapsed(startedAt: number): string { const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; } /** The one persistent line for a streaming session's own live filter/runtime commands (e.g. `cf request-trace`'s `/method`, `/pause`). Deliberately not `TextInputPrompt` — that component's Escape means "cancel the whole prompt," which would read as "stop this session" here; Escape/empty-Enter here just clears the line. */ function CommandBarLine(props: { placeholder?: string; onSubmit: (line: string) => void; onEmptyEnter?: () => void }) { const theme = useTerminalTheme(); const [value, setValue] = useState(""); useInput((input, key) => { if (key.return) { if (value.trim()) { props.onSubmit(value.trim()); setValue(""); } else { props.onEmptyEnter?.(); } return; } if (key.escape) { setValue(""); return; } if (key.backspace || key.delete) { setValue((current) => current.slice(0, -1)); return; } if (!key.ctrl && !key.meta && input) { setValue((current) => current + input); } }); return ( {"/ "} {value || {props.placeholder ?? "Type a runtime command, Enter to run"}} ); } /** * Reusable in-shell view for a long-running/tailing command (log follow, * HTTP watch, dev server, SSH tunnel relay, …) — the streaming-session * counterpart to a native workflow screen + InteractionHost. Auto-follows * the tail unless the user has scrolled up; caps its own rendered rows to * `maxVisibleRows` since Ink doesn't clip an overflowing live frame cleanly. */ export function StreamingOutputScreen(props: { service: StreamingSessionService; title: string; onDone: (success: boolean) => void; maxVisibleRows?: number; commandBar?: { placeholder?: string; onSubmit: (line: string) => void }; }) { const theme = useTerminalTheme(); const { lines, truncatedCount, status, exitCode } = useStreamingSession(props.service); const [followTail, setFollowTail] = useState(true); const [scrollOffset, setScrollOffset] = useState(0); const chromeRows = 1 + (truncatedCount > 0 ? 1 : 0) + (props.commandBar ? 1 : 0); const viewportRows = Math.max(3, (props.maxVisibleRows ?? 20) - chromeRows); const maxOffset = Math.max(0, lines.length - viewportRows); const effectiveOffset = followTail ? maxOffset : Math.min(scrollOffset, maxOffset); const visibleLines = lines.slice(effectiveOffset, effectiveOffset + viewportRows); const dismiss = () => props.onDone(status !== "failed"); useInput( (input, key) => { if (key.upArrow) { setFollowTail(false); setScrollOffset(Math.max(0, effectiveOffset - 1)); return; } if (key.downArrow) { const next = Math.min(maxOffset, effectiveOffset + 1); setScrollOffset(next); if (next >= maxOffset) { setFollowTail(true); } return; } if (input === "f") { setFollowTail(true); return; } if (key.return && status !== "running") { dismiss(); } }, { isActive: !props.commandBar }, ); const statusSymbol = status === "running" ? "●" : status === "failed" ? "✕" : "■"; const statusColor = status === "running" ? theme.info : status === "failed" ? theme.danger : theme.muted; const statusLabel = status === "running" ? "running" : status === "failed" ? `failed${exitCode !== undefined ? ` (exit ${exitCode})` : ""}` : "stopped"; return ( {statusSymbol} {props.title} — {statusLabel} — {formatElapsed(props.service.startedAt)} {!followTail ? {" (scrolled — f to follow)"} : null} {status !== "running" && !props.commandBar ? {" — Enter to dismiss"} : null} {truncatedCount > 0 ? ( ↑ {truncatedCount} earlier line{truncatedCount === 1 ? "" : "s"} truncated (kept last 2,000) ) : null} {visibleLines.map((line) => ( {line.tag ? [{line.tag}] : null} {line.text} ))} {props.commandBar ? ( ) : null} ); }