// A scrolling feed of ASK/ANSWER/INSIGHT history entries as they land, // with a filter bar (repo, Lane, ask kind, coordinator-vs-open role) // cycled by key presses -- the "watch it happen live" building block, as // opposed to reading a static dump after the fact. Scrolling itself // (up/down, Page Up/Down, Home/End) is handled by the parent TabView, // which passes down the resulting offset; j/k/r/L/t/s below are filter // shortcuts, kept disjoint from TabView's own scroll/tab keys. import React, { useMemo, useState } from "react"; import { Box, Text, useInput, useStdin } from "ink"; import { ASK_SCOPE } from "../engine/lane.js"; import type { FlatEvent } from "./types.js"; import { useTerminalSize } from "./useTerminalSize.js"; import { YELLOW, GREEN, CYAN, MUTED, PINK } from "./theme.js"; function eventParts(e: FlatEvent): { label: string; color: string; rest: string } { if (e.entry.type === "ASK") return { label: "ASK", color: YELLOW, rest: `${e.entry.kind} from ${e.entry.from}` }; if (e.entry.type === "ANSWER") return { label: "ANSWER", color: GREEN, rest: `${e.entry.action} (${e.entry.ask}) from ${e.entry.from}` }; return { label: "NOTE", color: CYAN, rest: `${e.entry.kind}: ${e.entry.text}` }; } function kindOf(e: FlatEvent): string | null { return e.entry.type === "ASK" ? e.entry.kind : null; } function scopeOf(e: FlatEvent): "coordinator" | "open" | "maintainer" | null { if (e.entry.type !== "ASK") return null; return ASK_SCOPE[e.entry.kind] ?? null; } function cycle(values: T[], current: T): T { const i = values.indexOf(current); return values[(i + 1) % values.length]; } export function LiveLog({ events, watchedRepos = [], maxRows, scrollOffset }: { events: FlatEvent[]; /** Every repo currently being watched, not just ones with at least one event -- without this, a repo with no protocol activity yet (a brand-new Lane, or one with no Lane at all) never appeared in the "r" cycle at all, which looked indistinguishable from "doesn't know this repo exists" rather than "exists, nothing to show." */ watchedRepos?: string[]; maxRows: number; scrollOffset: number }) { const repos = useMemo(() => ["all", ...Array.from(new Set([...watchedRepos, ...events.map((e) => e.repo)]))], [events, watchedRepos]); const lanes = useMemo(() => ["all", ...Array.from(new Set(events.map((e) => e.laneId)))], [events]); const kinds = useMemo(() => ["all", ...Array.from(new Set(events.map(kindOf).filter((k): k is string => k !== null)))], [events]); const [repoFilter, setRepoFilter] = useState("all"); const [laneFilter, setLaneFilter] = useState("all"); const [kindFilter, setKindFilter] = useState("all"); const [scope, setScope] = useState<"all" | "coordinator" | "open" | "maintainer">("all"); const { isRawModeSupported } = useStdin(); // Wide enough for repo/lane/verb/detail to all fit on one line with // real column separation -- narrower than that, the detail line // drops below instead of getting squeezed into illegibility. const { columns } = useTerminalSize(); const wide = columns > 80; useInput( (input) => { if (input === "r") setRepoFilter((v) => cycle(repos, repos.includes(v) ? v : "all")); if (input === "L") setLaneFilter((v) => cycle(lanes, lanes.includes(v) ? v : "all")); if (input === "t") setKindFilter((v) => cycle(kinds, kinds.includes(v) ? v : "all")); if (input === "s") setScope((v) => (v === "all" ? "coordinator" : v === "coordinator" ? "maintainer" : v === "maintainer" ? "open" : "all")); }, { isActive: isRawModeSupported }, ); const filtered = events.filter((e) => { if (repoFilter !== "all" && e.repo !== repoFilter) return false; if (laneFilter !== "all" && e.laneId !== laneFilter) return false; if (kindFilter !== "all" && kindOf(e) !== kindFilter) return false; if (scope !== "all") { const s = scopeOf(e); if (s !== null && s !== scope) return false; } return true; }); // One row per event on a wide terminal (repo/lane/verb/detail all // fit), two on a narrower one (detail drops to its own line below) -- // see the row rendering's own comment for why. const windowSize = wide ? Math.max(1, maxRows - 1) : Math.max(1, Math.floor((maxRows - 1) / 2)); const maxOffset = Math.max(0, filtered.length - windowSize); const offset = Math.min(scrollOffset, maxOffset); const end = filtered.length - offset; const shown = filtered.slice(Math.max(0, end - windowSize), end); return ( filter: repo=[{repoFilter}](r) lane=[{laneFilter}](L) kind=[ {kindFilter}](t) role=[{scope}](s) --{" "} {filtered.length === 0 ? "no events yet" : `showing ${Math.max(0, end - windowSize) + 1}-${end} of ${filtered.length}${offset > 0 ? ` -- ${offset} back (End to catch up)` : ", live"}`} {shown.length === 0 ? ( (nothing matches) ) : ( shown.map((e, i) => { const { label, color, rest } = eventParts(e); const columns = ( <> {e.repo} {e.laneId} {label} ); return wide ? ( {columns} {rest} ) : ( {columns} {rest} ); }) )} ); }