import { ScrollView, VStack, truncateToWidth, type Component } from "@earendil-works/pi-tui"; import { createRequire } from "node:module"; import type { getLayoutNode as GetLayoutNode } from "@earendil-works/pi-tui/dist/layout-node.js"; interface StickyToolView { pinnedTool(): { id: string; y: number; line: string; autoScroll?: boolean } | undefined; unpinTool(): void; toggleTool(id: string): void; } /** ponytail: Pi 0.85 layoutRoot + optional internal layout-node export; * replace discovery with a public primary-scroll/layout getter when available. */ export function attachStickyTool(tui: unknown, document: Component, view: StickyToolView, setMinHeight: (height: number) => void = () => {}): (() => void) | undefined { const host = tui as { mode?: string; terminal?: { rows: number }; layoutRoot?: Component; setLayoutRoot?: (root: Component) => void; requestRender?: () => void; scrollToEndIndicator?: () => string }; if (host.mode !== "fullscreen" || !host.layoutRoot || !host.setLayoutRoot || !host.requestRender) return; let getNode: typeof GetLayoutNode; try { getNode = createRequire(import.meta.url)("@earendil-works/pi-tui/dist/layout-node.js").getLayoutNode; } catch { return; } if (typeof getNode !== "function") return; const originalRoot = host.layoutRoot; let scroll: ScrollView | undefined; let primaries = 0; const seen = new Set(); const inspect = (component: Component): boolean => { if (seen.has(component)) return false; seen.add(component); const node = getNode(component); if (!node) return true; if (node.type === "scroll") { if (node.state.primary) { primaries++; if (component instanceof ScrollView && node.component === document) scroll = component; } return true; } if (node.type !== "vstack" || !(component instanceof VStack)) return false; return node.entries.every(entry => inspect(entry.component)); }; try { if (!inspect(originalRoot) || primaries !== 1 || !scroll) return; } catch { return; } const transcript = scroll; let currentId: string | undefined; let shown: { id: string; line: string } | undefined; let contentHeight = 0; // Pi shows this indicator whenever follow-end is suspended, including at the // content end. Keep expansion's reading position but only hint at hidden rows. const indicatorDescriptor = Object.getOwnPropertyDescriptor(host, "scrollToEndIndicator"); const originalIndicator = host.scrollToEndIndicator; const indicator = () => transcript.scrollTop + transcript.viewportHeight < contentHeight ? originalIndicator?.call(host) ?? "" : ""; if (originalIndicator) host.scrollToEndIndicator = indicator; const heading: Component = { invalidate() {}, render(width) { // Measure the unchanged document before moving the existing ScrollView. setMinHeight(0); contentHeight = document.render(transcript.getContentWidth(width)).length; const target = view.pinnedTool(); if (!target) { currentId = undefined; shown = undefined; return []; } if (target.id !== currentId) { currentId = target.id; // Suspend follow-end before layout grows, without moving the reading position. transcript.scrollTo(transcript.scrollTop, { disableFollow: true }); } // At the original heading, use its document row rather than duplicating it. shown = transcript.scrollTop > target.y ? target : undefined; return shown ? [truncateToWidth(width < 5 ? "▼" : shown.line, width, "")] : []; }, handleMouse(event) { if (!shown) return; if (event.type === "wheel") { transcript.scrollBy(event.wheelDelta ?? 0); return { handled: true, render: true }; } // Dedicated layout row: never dispatch its clicks into underlying links. if (event.button === "left" && !event.shift && !event.ctrl && !event.alt) { if (event.type === "press") return { handled: true }; if (event.type === "click") { view.toggleTool(shown.id); return { handled: true, render: true }; } } }, }; const section = new VStack([ { component: heading, basis: "auto", shrink: 0 }, { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 }, ]); const replace = (component: Component): Component => { if (component === transcript) return section; const node = getNode(component); if (node?.type !== "vstack") return component; const entries = node.entries.map(entry => ({ ...entry, component: replace(entry.component) })); return entries.some((entry, index) => entry.component !== node.entries[index].component) ? new VStack(entries, { gap: node.gap, align: node.align }) : component; }; const root = replace(originalRoot); host.setLayoutRoot(root); return () => { view.unpinTool(); setMinHeight(0); if (host.scrollToEndIndicator === indicator) { if (indicatorDescriptor) Object.defineProperty(host, "scrollToEndIndicator", indicatorDescriptor); else delete host.scrollToEndIndicator; } if (host.layoutRoot === root) host.setLayoutRoot!(originalRoot); }; }