// A real full-screen tabbed frame: fixed to the terminal's actual size // (see useTerminalSize) with a bordered content pane sized to fit, so // nothing spills into scrollback. Left/right arrows (or h/l) switch // tabs; up/down arrows (or k/j), Page Up/Down, Home/End, and the mouse // wheel scroll the active tab's content -- each content renderer // decides what a given scroll offset actually means (older entries for // a log, earlier rows for a list) since that differs per view. y copies // the active tab's full (untruncated) content to the system clipboard. // q or Ctrl+C quits (restoring the normal screen first). import React, { useEffect, useState } from "react"; import { Box, Text, useInput, useStdin } from "ink"; import { spawn } from "node:child_process"; import { useTerminalSize } from "./useTerminalSize.js"; import { useMouseWheel } from "./useMouseWheel.js"; import { exitFullscreen } from "./fullscreen.js"; export interface Tab { label: string; /** Given how many rows are available and the current scroll offset (0 = latest/top), render this tab's content sized to fit. */ content: (maxRows: number, scrollOffset: number) => React.ReactNode; /** The full, untruncated text of this tab right now -- used by the "y" copy-to-clipboard key. Omit for a tab that has nothing meaningful to copy as plain text. */ getFullText?: () => string; } const CHROME_ROWS = 5; // title/tab bar (1) + blank (1) + content box border (2) + footer (1) function copyToClipboard(text: string): void { // macOS only, matching this project's one supported platform -- pipe // directly into pbcopy's stdin rather than shelling through a string. const proc = spawn("pbcopy"); proc.stdin.write(text); proc.stdin.end(); } export function TabView({ title, tabs }: { title: string; tabs: Tab[] }) { const [active, setActive] = useState(0); const [scroll, setScroll] = useState(0); const [flash, setFlash] = useState(null); const { isRawModeSupported } = useStdin(); const { rows, columns } = useTerminalSize(); const contentHeight = Math.max(3, rows - CHROME_ROWS); const pageSize = Math.max(1, contentHeight - 2); useEffect(() => setScroll(0), [active]); useEffect(() => { if (!flash) return; const id = setTimeout(() => setFlash(null), 1500); return () => clearTimeout(id); }, [flash]); const scrollUp = () => setScroll((s) => s + 1); const scrollDown = () => setScroll((s) => Math.max(0, s - 1)); useMouseWheel(scrollUp, scrollDown); useInput( (input, key) => { if (input === "q" || (key.ctrl && input === "c")) { exitFullscreen(); process.exit(0); } if (key.rightArrow || input === "l") setActive((i) => (i + 1) % tabs.length); else if (key.leftArrow || input === "h") setActive((i) => (i - 1 + tabs.length) % tabs.length); else if (key.upArrow || input === "k") scrollUp(); else if (key.downArrow || input === "j") scrollDown(); else if (key.pageUp) setScroll((s) => s + pageSize); else if (key.pageDown) setScroll((s) => Math.max(0, s - pageSize)); else if (key.home) setScroll(Number.MAX_SAFE_INTEGER); else if (key.end) setScroll(0); else if (input === "y") { const getFullText = tabs[active]?.getFullText; if (getFullText) { copyToClipboard(getFullText()); setFlash(`copied "${tabs[active].label}" tab to clipboard`); } else { setFlash(`nothing to copy on the "${tabs[active]?.label}" tab`); } } else { const n = Number(input); if (Number.isInteger(n) && n >= 1 && n <= tabs.length) setActive(n - 1); } }, { isActive: isRawModeSupported }, ); return ( {title} {" "} {tabs.map((t, i) => ( {i + 1}:{t.label} ))} {tabs[active]?.content(pageSize, scroll)} {flash ?? (isRawModeSupported ? "←/→ or 1-9: tabs ↑/↓ wheel PgUp/PgDn Home/End: scroll y: copy tab q: quit" : "(non-interactive terminal -- controls disabled)")} ); }