/** * Floating overlay for the /btw side thread. * * Layout (fixed, top to bottom): * ╭─ btw ─────────────────────────── provider/model ─╮ * │ 3 side questions │ * │ ❯ how does the retry budget work? │ * │ (answer rendered as Markdown) │ * │ ──────────────────────────────────────────────── │ * │ … 1 earlier │ * │ · what was that config file called again? │ * │ ❯ how does the retry budget work? │ * │ ↑/↓ to scroll · ⇧←/→ to browse · … 2/3 │ * ╰───────────────────────────────────────────────────╯ * * Only the body scrolls; the title bar and footer stay in place. `⇧←/→` moves * the selection: the selected exchange is the one expanded in the body and the * one `e`, `c`, `f` and `x` act on, and the dimmed window below the separator * lists the exchanges around it while marking the selected one, mirroring Claude * Code's overlay. * * A question that wraps to more than `QUESTION_COLLAPSE_LINES` rows is folded to * its first line plus a `… N more lines (e to show)` marker, and `e` unfolds it. * Without the fold a long question fills the whole body and pushes the answer or * the pending spinner out of view; with it a state change only ever reveals the * answer, the spinner or the error it belongs to. */ import { randomUUID } from "node:crypto"; import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent"; import { Key, Markdown, matchesKey, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TUI, type TuiMouseEvent, type TuiMouseEventResult, } from "@earendil-works/pi-tui"; import { appendExchange, removeExchange, type BtwExchange } from "./thread.ts"; /** What the overlay resolves to when it closes. */ export type BtwOverlayResult = { action: "close" } | { action: "fork"; exchange: BtwExchange }; /** Outcome of one side request. */ export type BtwQueryOutcome = | { kind: "answer"; answer: string } | { kind: "error"; message: string } | { kind: "cancelled" }; /** Thread mutations the overlay performs; the command handler persists them. */ export interface BtwThreadSink { /** A new exchange finished and must be persisted. */ add(exchange: BtwExchange): void; /** The exchange with this id was deleted and must leave the persisted thread. */ remove(id: string): void; } export interface BtwOverlayInit { tui: TUI; theme: Theme; /** Side thread as restored from the session, oldest first. */ thread: readonly BtwExchange[]; /** New question to ask, or null to only browse the existing thread. */ question: string | null; /** Model label shown in the title bar. */ modelLabel: string; /** Number of earlier exchanges replayed into the request this overlay starts. */ replayedCount: number; /** Runs the side request; present exactly when `question` is set. */ ask?: (signal: AbortSignal) => Promise; sink: BtwThreadSink; /** Copies text to the clipboard; throws when the host cannot copy. */ copy(text: string): Promise; done(result: BtwOverlayResult): void; } /** Exchanges listed around the selected one below the separator. */ const WINDOW_SIZE = 5; /** * Key hints in display order. * * Every hint must survive a narrow terminal except the one at `DROPPABLE_HINT`: * the footer wraps onto as many rows as it needs, and `x to clear history` is * given up only when keeping it would cost an extra row. */ const HINT_PIECES = ["↑/↓ to scroll", "⇧←/→ to browse", "c to copy", "f to fork", "x to clear history", "Esc to close"]; const DROPPABLE_HINT = 4; /** Spinner frames for an in-flight side request. */ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /** Spinner refresh interval; slow enough to stay cheap, fast enough to look alive. */ const SPINNER_INTERVAL_MS = 90; /** How long a transient footer message stays visible. */ const FLASH_MS = 2000; /** Share of terminal rows used by the scrolling body. */ const BODY_ROWS_RATIO = 0.6; /** * Height limit of the overlay as a percentage of the terminal. * * Exported because the command handler passes it to the host as the * `maxHeight` overlay option and the body budget here must match that limit. */ export const OVERLAY_MAX_HEIGHT_PERCENT = 85; /** Rows of terminal margin around the overlay, matching the host's `margin` option. */ export const OVERLAY_MARGIN = 1; /** * Overlay width as a percentage of the terminal, and the narrowest width it keeps. * * Exported because the command handler passes both to the host as the `width` and * `minWidth` overlay options: the host resolves them to an exact column count, and * tests that render the component directly have to reproduce that resolution * instead of guessing a width. */ export const OVERLAY_WIDTH_PERCENT = 80; export const OVERLAY_MIN_WIDTH = 60; /** Minimum and maximum body height in rows. */ const MIN_BODY_ROWS = 4; const MAX_BODY_ROWS = 30; /** * Wrapped question rows a question may take before it is folded. * * Three rows fit next to the answer on every supported terminal; a longer * question would leave the body with no room for what the reader came for. */ const QUESTION_COLLAPSE_LINES = 3; export class BtwOverlay implements Component { private readonly tui: TUI; private readonly theme: Theme; private readonly modelLabel: string; private readonly replayedCount: number; private readonly sink: BtwThreadSink; private readonly copyText: (text: string) => Promise; private readonly done: (result: BtwOverlayResult) => void; private thread: BtwExchange[]; /** Ids of exchanges whose long question is unfolded; every exchange starts folded. */ private readonly expandedIds = new Set(); /** Unfold state of the in-flight question, carried over to the exchange it becomes. */ private pendingExpanded = false; /** * Anchor the next paint scrolls to after a state change. * * `questionTop` is the explicit unfold request, `status` and `answer` are the * two blocks a finished request produces; null means the reader owns the scroll * position and no reveal may move it. */ private reveal: "questionTop" | "status" | "answer" | null = null; /** First text row of the selected answer inside `contentLines`; -1 when absent. */ private answerStart = -1; /** First text row of the pending spinner or error inside `contentLines`; -1 when absent. */ private statusStart = -1; /** Index into `thread` of the exchange expanded in the body; always clamped. */ private selectedIndex = 0; /** Footer rows the last render needed; the body shrinks when the footer wraps. */ private footerRows = 1; private pending: { question: string; error?: string } | null = null; private abort: AbortController | null = null; private spinnerTimer: ReturnType | null = null; private spinnerFrame = 0; private flashState: { text: string; tone: "success" | "warning" | "error" } | null = null; private flashTimer: ReturnType | null = null; private disposed = false; private scrollTop = 0; private contentLines: string[] = []; private cachedWidth = -1; private dirty = true; constructor(init: BtwOverlayInit) { this.tui = init.tui; this.theme = init.theme; this.modelLabel = init.modelLabel; this.replayedCount = init.replayedCount; this.sink = init.sink; this.copyText = init.copy; this.done = init.done; this.thread = [...init.thread]; this.selectNewest(); if (init.question !== null && init.ask !== undefined) { this.startAsk(init.question, init.ask); } } handleInput(data: string): void { if (matchesKey(data, Key.escape)) { this.close(); return; } if (matchesKey(data, Key.up)) { this.scrollBy(-1); return; } if (matchesKey(data, Key.down)) { this.scrollBy(1); return; } // Shift+arrows browse the thread; plain arrows stay reserved for scrolling. if (matchesKey(data, Key.shift("left"))) { this.selectBy(-1); return; } if (matchesKey(data, Key.shift("right"))) { this.selectBy(1); return; } if (matchesKey(data, Key.pageUp)) { this.scrollBy(-this.pageStep()); return; } if (matchesKey(data, Key.pageDown)) { this.scrollBy(this.pageStep()); return; } if (matchesKey(data, Key.home)) { this.setScroll(0); return; } if (matchesKey(data, Key.end)) { this.setScroll(Number.MAX_SAFE_INTEGER); return; } // Enter closes as well, matching Space/Enter/Escape in Claude Code. if (matchesKey(data, Key.enter)) { this.close(); return; } if (data === "e") { this.toggleExpanded(); return; } if (data === "c") { void this.copySelectedAnswer(); return; } if (data === "f") { this.forkSelected(); return; } if (data === "x") { this.deleteSelected(); } } /** Wheel scrolling is only routed in fullscreen mode; keyboard always works. */ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { if (event.type !== "wheel" || event.wheelDelta === undefined) return undefined; this.scrollBy(event.wheelDelta); return { handled: true }; } render(width: number): string[] { const innerWidth = Math.max(24, width - 4); if (this.dirty || this.cachedWidth !== innerWidth) { const content = this.buildContent(innerWidth); this.contentLines = content.lines; this.answerStart = content.answerStart; this.statusStart = content.statusStart; this.cachedWidth = innerWidth; this.dirty = false; } const footer = this.footerLines(innerWidth); this.footerRows = footer.length; const bodyHeight = this.bodyHeight(); const maxScroll = Math.max(0, this.contentLines.length - bodyHeight); // The reveal runs after the content is measured, because both the target row // and the body height come from the freshly built lines. if (this.reveal !== null) { this.scrollTop = Math.min(Math.max(0, this.revealTarget(bodyHeight)), maxScroll); this.reveal = null; } if (this.scrollTop > maxScroll) this.scrollTop = maxScroll; const visible = this.contentLines.slice(this.scrollTop, this.scrollTop + bodyHeight); const lines: string[] = [this.titleBar(width)]; for (let row = 0; row < bodyHeight; row += 1) { lines.push(this.frame(visible[row] ?? "", innerWidth)); } for (const row of footer) { lines.push(this.frame(row, innerWidth)); } lines.push(this.bottomBar(width)); return lines; } invalidate(): void { this.dirty = true; } /** Called by the host when the overlay is removed. */ dispose(): void { this.disposed = true; this.abort?.abort(); this.stopSpinner(); if (this.flashTimer !== null) { clearTimeout(this.flashTimer); this.flashTimer = null; } } private startAsk(question: string, ask: (signal: AbortSignal) => Promise): void { this.pending = { question }; // A fresh question always starts folded, so the spinner under it is visible. this.pendingExpanded = false; this.reveal = "status"; this.abort = new AbortController(); this.spinnerTimer = setInterval(() => { this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; this.dirty = true; this.tui.requestRender(); }, SPINNER_INTERVAL_MS); void ask(this.abort.signal).then((outcome) => this.finishAsk(question, outcome)); } private finishAsk(question: string, outcome: BtwQueryOutcome): void { // The overlay can be disposed (Esc) before the request resolves. if (this.disposed) return; this.stopSpinner(); this.abort = null; switch (outcome.kind) { case "answer": { const exchange: BtwExchange = { id: randomUUID(), question, answer: outcome.answer, answeredAt: Date.now(), model: this.modelLabel, replayed: this.replayedCount, }; this.thread = appendExchange(this.thread, exchange); // The fold state the reader chose while waiting carries over to the // exchange the question becomes. if (this.pendingExpanded) this.expandedIds.add(exchange.id); this.pendingExpanded = false; this.pending = null; // A finished answer is what the reader wants to see next, so browsing // ends here and the body reveals the newest exchange: a folded question // keeps its exchange top, an unfolded one scrolls to the answer. The // exchange appearing in the body is the whole signal; no footer flash is // needed for it. this.selectNewest(); this.sink.add(exchange); this.reveal = "answer"; break; } case "error": this.pending = { question, error: outcome.message }; this.reveal = "status"; break; case "cancelled": this.pending = { question, error: "The side request was cancelled." }; this.reveal = "status"; break; } this.dirty = true; this.tui.requestRender(); } private close(): void { this.abort?.abort(); this.done({ action: "close" }); } private async copySelectedAnswer(): Promise { const selected = this.selectedExchange(); if (selected === undefined) { this.flash("Nothing to copy yet", "warning"); return; } try { await this.copyText(selected.answer); // Esc can dispose the overlay while the clipboard write is in flight; // a disposed overlay must not schedule a flash timer anymore. if (this.disposed) return; this.flash("Copied answer to clipboard"); } catch (error) { if (this.disposed) return; this.flash(error instanceof Error ? error.message : String(error), "error"); } } private forkSelected(): void { // Only a request that is still running blocks forking: closing the overlay // would abort it. A finished failure keeps `pending` for display but has no // request in flight anymore, so its exchange can be promoted as usual. if (this.abort !== null) { this.flash("Wait for the current side question to finish", "warning"); return; } const selected = this.selectedExchange(); if (selected === undefined) { this.flash("Nothing to fork yet", "warning"); return; } if (selected.promoted === true) { this.flash("This exchange is already in the main conversation", "warning"); return; } this.done({ action: "fork", exchange: selected }); } private deleteSelected(): void { const selected = this.selectedExchange(); if (selected === undefined) { this.flash("Nothing to clear yet", "warning"); return; } this.thread = removeExchange(this.thread, selected.id); this.sink.remove(selected.id); this.expandedIds.delete(selected.id); // Deleting moves the reader back to the newest remaining exchange, so a // deletion never leaves the body pointing at an evicted index. this.selectNewest(); this.dirty = true; this.reveal = "answer"; this.flash("Deleted this side question"); } /** Exchange the body expands and that `c`, `f` and `x` act on. */ private selectedExchange(): BtwExchange | undefined { return this.thread[this.selectedIndex]; } /** Put the selection back on the newest exchange. */ private selectNewest(): void { this.selectedIndex = Math.max(0, this.thread.length - 1); } private selectBy(delta: number): void { // `Math.max(0, ...)` on the upper bound keeps an empty thread clamped to 0. const next = Math.min(Math.max(0, this.selectedIndex + delta), Math.max(0, this.thread.length - 1)); if (next === this.selectedIndex) return; this.selectedIndex = next; this.dirty = true; // The selected exchange builds the body from its first row, so browsing // lands on the answer of a long unfolded question and on the whole folded // block otherwise. this.reveal = "answer"; this.tui.requestRender(); } /** `e` folds or unfolds the question the reader is looking at. */ private toggleExpanded(): void { const question = this.pending?.question ?? this.selectedExchange()?.question; if (question === undefined || !this.isCollapsible(question)) return; if (this.pending !== null) { this.pendingExpanded = !this.pendingExpanded; } else { const id = this.selectedExchange()?.id; if (id === undefined) return; if (this.expandedIds.has(id)) this.expandedIds.delete(id); else this.expandedIds.add(id); } this.dirty = true; // Unfolding is a request to read the question from its first line; folding // gives the body back to the answer or the spinner below it. const expanded = this.pending !== null ? this.pendingExpanded : this.isSelectedExpanded(); this.reveal = expanded ? "questionTop" : this.pending !== null ? "status" : "answer"; this.tui.requestRender(); } /** Whether the selected exchange has a long question unfolded right now. */ private isSelectedExpanded(): boolean { const selected = this.selectedExchange(); if (selected === undefined) return false; return this.expandedIds.has(selected.id) && this.isCollapsible(selected.question); } /** * Whether the question needs more rows than `QUESTION_COLLAPSE_LINES` at the last rendered width. * * `cachedWidth` is only set by a paint. Before the first one no width is known, and measuring at * -1 would report every question as folded, so a fold key arriving then has to be ignored * instead of recording a decision the question does not deserve. */ private isCollapsible(question: string): boolean { if (this.cachedWidth <= 0) return false; return wrapTextWithAnsi(this.questionLine(question), this.cachedWidth).length > QUESTION_COLLAPSE_LINES; } /** * Body row the next state change scrolls to. * * An unfolded question is taller than the body, so the answer (or the pending * status line) is pinned to the top and the question scrolls away above it. A * folded block is short enough to keep its exchange top, and the viewport only * moves as far as a very short body needs to keep the target row visible. */ private revealTarget(bodyHeight: number): number { if (this.reveal === "questionTop") return 0; const expanded = this.pending !== null ? this.pendingExpanded : this.isSelectedExpanded(); const target = this.reveal === "status" ? this.statusStart : this.answerStart; if (expanded) return target; return target < bodyHeight ? 0 : target - bodyHeight + 1; } private flash(text: string, tone: "success" | "warning" | "error" = "success"): void { this.flashState = { text, tone }; if (this.flashTimer !== null) clearTimeout(this.flashTimer); this.flashTimer = setTimeout(() => { this.flashState = null; this.flashTimer = null; this.tui.requestRender(); }, FLASH_MS); this.tui.requestRender(); } private stopSpinner(): void { if (this.spinnerTimer !== null) { clearInterval(this.spinnerTimer); this.spinnerTimer = null; } } private scrollBy(delta: number): void { this.setScroll(this.scrollTop + delta); } private setScroll(top: number): void { const max = Math.max(0, this.contentLines.length - this.bodyHeight()); const next = Math.min(Math.max(0, top), max); if (next === this.scrollTop) return; this.scrollTop = next; this.tui.requestRender(); } private pageStep(): number { return Math.max(1, this.bodyHeight() - 1); } /** * Body rows for the current footer. * * The body absorbs the footer's extra rows so the overlay keeps its share of * the terminal, and it never exceeds the host's height budget: the footer * carries the key hints, so it wins over the content and the body gives up all * of its rows if that is what the budget leaves. The hints need four rows at * the narrowest width, so a terminal shorter than eight rows cannot always hold * the title, the border and every hint; there the host clips the overlay bottom. */ private bodyHeight(): number { const target = Math.floor(this.tui.terminal.rows * BODY_ROWS_RATIO) - (this.footerRows - 1); const budget = Math.max(0, this.maxOverlayRows() - 2 - this.footerRows); const bounded = Math.min(Math.max(target, MIN_BODY_ROWS), MAX_BODY_ROWS, budget); return Math.min(bounded, Math.max(MIN_BODY_ROWS, this.contentLines.length)); } /** Rows the host allows for the whole overlay: title + body + footer + border. */ private maxOverlayRows(): number { const available = Math.max(1, this.tui.terminal.rows - 2 * OVERLAY_MARGIN); return Math.max(1, Math.min(Math.floor((this.tui.terminal.rows * OVERLAY_MAX_HEIGHT_PERCENT) / 100), available)); } private buildContent(innerWidth: number): { lines: string[]; answerStart: number; statusStart: number } { const lines: string[] = []; let answerStart = -1; let statusStart = -1; /** * Append one child and return the row its first text line lands on. * * `paddingTop` is the child's vertical padding: a blank row above the text * means the reader would see that empty row first if a reveal pinned the * child itself, so the mark has to skip it. */ const push = (component: Component, paddingTop = 0): number => { const start = lines.length + paddingTop; for (const line of component.render(innerWidth)) lines.push(line); return start; }; const pending = this.pending; const selected = this.selectedExchange(); if (pending !== null) { // The in-flight question takes the expanded slot: every finished // exchange moves into the dimmed window below it. Unlike the finished // exchange it has no blank row above it, so the spinner stays close. push(new Text(this.questionBlockText(pending.question, this.pendingExpanded, innerWidth), 0, 0)); statusStart = push( new Text( pending.error !== undefined ? this.theme.fg("error", pending.error) : this.theme.fg("dim", `${SPINNER_FRAMES[this.spinnerFrame]} asking ${this.modelLabel}…`), 0, 1, ), 1, ); } else if (selected === undefined) { push(new Text(this.theme.fg("dim", "No side questions in this session yet. Run /btw to ask one."), 0, 0)); } else { const count = this.thread.length; push(new Text(this.theme.fg("dim", `${count} side question${count === 1 ? "" : "s"}`), 0, 0)); push(new Text(this.questionBlockText(selected.question, this.isSelectedExpanded(), innerWidth), 0, 1)); answerStart = push(new Markdown(selected.answer, 0, 1, getMarkdownTheme()), 1); } const listed = this.windowEntries(); if (listed.length > 0) { push(new Spacer(1)); push(new Text(this.theme.fg("borderMuted", "─".repeat(innerWidth)), 0, 0)); push(new Spacer(1)); const { start, end } = this.windowRange(); if (start > 0) { push(new Text(this.theme.fg("dim", `… ${start} earlier`), 0, 0)); } for (const entry of listed) { // The selected exchange is expanded above and listed here as well: the // window doubles as the position marker, so it has to show where the // reader currently is even while the expanded block is scrolled away. const marker = entry.selected ? this.theme.fg("accent", "❯") : this.theme.fg("muted", "·"); const line = truncateToWidth(`${marker} ${this.theme.fg("muted", entry.exchange.question)}`, innerWidth, "…"); push(new Text(line, 0, 0)); } if (end < this.thread.length) { push(new Text(this.theme.fg("dim", `… ${this.thread.length - end} newer`), 0, 0)); } } return { lines, answerStart, statusStart }; } /** * The dimmed window of exchanges around the selection, oldest first. * * The window keeps the selected exchange centered so browsing stays local and * reports how many exchanges fall outside it. The selected exchange is in the * window even though it is expanded above the separator, because the window is * what tells the reader which exchange the selection is on. */ private windowEntries(): { exchange: BtwExchange; selected: boolean }[] { const { start, end } = this.windowRange(); const entries: { exchange: BtwExchange; selected: boolean }[] = []; for (let index = start; index < end; index += 1) { const exchange = this.thread[index]; if (exchange === undefined) continue; entries.push({ exchange, selected: index === this.selectedIndex }); } return entries; } private windowRange(): { start: number; end: number } { const before = Math.floor((WINDOW_SIZE - 1) / 2); const start = Math.min(Math.max(0, this.selectedIndex - before), Math.max(0, this.thread.length - WINDOW_SIZE)); return { start, end: Math.min(this.thread.length, start + WINDOW_SIZE) }; } private questionLine(question: string): string { return this.theme.fg("accent", "❯ ") + this.theme.fg("text", question); } /** * Rendered question block: the full question, or its first line plus a marker. * * A question that wraps to more than `QUESTION_COLLAPSE_LINES` rows is folded, * because it would otherwise push the answer or the spinner out of the body. * The marker states how many rows are hidden so nothing disappears silently, * and carries the `e` hint: a footer hint for it would push the key hints onto * an extra row, which costs the body its own row on every render. */ private questionBlockText(question: string, expanded: boolean, innerWidth: number): string { const rendered = wrapTextWithAnsi(this.questionLine(question), innerWidth); if (rendered.length <= QUESTION_COLLAPSE_LINES) return rendered.join("\n"); if (expanded) return [...rendered, this.theme.fg("dim", " (e to hide)")].join("\n"); const hidden = rendered.length - 1; // Cut the first line one column short so the ellipsis fits inside the frame. const first = truncateToWidth(rendered[0] ?? "", innerWidth - 1, "") + "…"; return `${first}\n${this.theme.fg("dim", ` … ${hidden} more line${hidden === 1 ? "" : "s"} (e to show)`)}`; } private titleBar(width: number): string { const left = "╭─" + this.theme.fg("accent", " btw ") + this.theme.fg("border", "─"); const labelWidth = Math.min(visibleWidth(this.modelLabel), Math.max(0, width - 14)); const right = this.theme.fg("border", "─") + " " + this.theme.fg("dim", truncateToWidth(this.modelLabel, labelWidth, "…")) + " " + this.theme.fg("border", "╮"); const fill = Math.max(0, width - visibleWidth(left) - visibleWidth(right)); return left + this.theme.fg("border", "─".repeat(fill)) + right; } private bottomBar(width: number): string { return this.theme.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`); } private frame(content: string, innerWidth: number): string { const clipped = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth, "") : content; const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped))); return this.theme.fg("border", "│") + " " + clipped + padding + " " + this.theme.fg("border", "│"); } /** * Key hints and the browse position, on one or more rows. * * The overlay is 80% of the terminal, so an 80-column terminal leaves a * 60-column body and the hints do not fit next to each other. Hints stay * complete: they wrap onto extra rows, and only `x to clear history` is given * up, and only when keeping it would need a row of its own. */ private footerLines(innerWidth: number): string[] { if (this.flashState !== null) { return [truncateToWidth(this.theme.fg(this.flashState.tone, this.flashState.text), innerWidth, "…")]; } const required = HINT_PIECES.filter((_piece, index) => index !== DROPPABLE_HINT); const position = this.thread.length === 0 ? "" : `${this.selectedIndex + 1}/${this.thread.length}`; // Complete hints come first, then the droppable hint, then the browse // position. A row that would overflow wraps instead of losing a hint. for (const pieces of [HINT_PIECES, required]) { const row = pieces.join(" · "); if (visibleWidth(row) + this.positionGap(position) <= innerWidth) { return this.withPosition([row], position, innerWidth); } } const wrappedAll = this.wrapHints(HINT_PIECES, innerWidth); const wrappedRequired = this.wrapHints(required, innerWidth); // Fewest rows win, so the droppable hint is the one that goes when it would // occupy a row on its own; on a tie the wrapped list keeping it is used. const chosen = wrappedAll.length <= wrappedRequired.length ? wrappedAll : wrappedRequired; return this.withPosition(chosen, position, innerWidth); } /** Columns the right-aligned browse position needs, gap included. */ private positionGap(position: string): number { return position === "" ? 0 : 2 + visibleWidth(position); } /** Greedy wrap of the hint pieces into rows that fit `innerWidth`. */ private wrapHints(pieces: readonly string[], innerWidth: number): string[] { const rows: string[] = []; let row = ""; for (const piece of pieces) { const candidate = row === "" ? piece : `${row} · ${piece}`; if (row !== "" && visibleWidth(candidate) > innerWidth) { rows.push(row); row = piece; } else { row = candidate; } } if (row !== "") rows.push(row); return rows; } /** * Right-align the browse position on the last hint row when a two-column gap * still fits. `wrapHints` already keeps every row within `innerWidth`, so the * rows themselves need no truncation. */ private withPosition(rows: readonly string[], position: string, innerWidth: number): string[] { const last = rows[rows.length - 1] ?? ""; const lastWidth = visibleWidth(last); const placement = position !== "" && lastWidth + this.positionGap(position) <= innerWidth ? " ".repeat(innerWidth - lastWidth - visibleWidth(position)) + position : ""; return rows.map((row, index) => this.theme.fg("dim", index === rows.length - 1 ? row + placement : row)); } }