// 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 { 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(); 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; }); // Two rows per event now (repo/lane/verb, then the detail line below // it) -- see the row rendering's own comment for why. const windowSize = 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); return ( {e.repo} {e.laneId} {label} {rest} ); }) )} ); }