import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugePaint, type GaugeTone } from "./shell-gauge.ts"; import { renderUsageBar, selectUsageLimit, type ProviderUsage, type UsageWindow } from "./shell-usage.ts"; import { sanitizeTerminalText } from "./terminal-theme.ts"; import { CARD_TONE, floatRows, panelInnerWidth, renderCard } from "./shell-card.ts"; import { REVIEW_SCOPE_UNAVAILABLE, REVIEW_SIDEBAR_LABELS, type ReviewSidebarSnapshot } from "./review-sidebar-state.ts"; import type { VisualSettings } from "./visual-customization-policy.ts"; import { renderChangesWidget, type ChangesModel } from "./shell-changes.ts"; import type { RunningJobDisplay } from "./jobs-sidebar-state.ts"; import { JOB_GLYPH } from "./background-jobs-tools.ts"; type Presentation = Pick; type HeaderPresentation = Presentation & Partial>; export { gaugeTone, renderGauge, type GaugeTone }; // Gentle Shell status bar: one line of segments that replaces pi's built-in // three-line footer. Everything here is pure so the bar can be rendered and // verified without a live TUI. export interface ShellBarModel { review?: ReviewSidebarSnapshot; jobs?: RunningJobDisplay[]; profile?: string; changes?: { files: number; added: number; deleted: number }; cwd: string; branch: string | null; dirty: number | undefined; sessionName: string | undefined; modelId: string; effort: string | undefined; contextPercent: number | null; contextWindow: number; costTotal: number; subscription: boolean; usage: ProviderUsage | undefined; statuses: string[]; } // The live header row above the fullscreen rail: session identity plus the // live counters. Only the structured jobs count may join context/cost when // Status is absent; opaque extension statuses and working state stay excluded. export interface ShellHeaderModel { jobs?: number; cwd: string; branch: string | null; dirty: number | undefined; modelId: string; effort: string | undefined; profile?: string; contextPercent: number | null; costTotal: number; subscription: boolean; // The active provider's subscription usage, shown as its own segment after // cost. Unlike the sidebar's old per-model usage table, this is one // compact line — the same windows the compact bar already meters. usage: ProviderUsage | undefined; } export function buildShellHeaderModel(model: ShellBarModel, showJobs = false): ShellHeaderModel { const { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage } = model; return { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage, ...(showJobs && model.jobs?.length ? { jobs: model.jobs.length } : {}) }; } export function shellJobsCount(count: number): string { return `${JOB_GLYPH} ${count} job${count === 1 ? "" : "s"}`; } /** A column span (`[start, end)`, in the rendered line's visible columns) a click must land in to hit the usage segment. */ export interface HeaderUsageSpan { start: number; end: number; } export interface ShellHeaderResult { text: string; usageSpan?: HeaderUsageSpan; } export interface ShellHeaderChrome { rows: string[]; headerRow: number; usageSpan?: HeaderUsageSpan; } export interface ShellBarTheme { fg(color: string, text: string): string; bold(text: string): string; } // Theme roles the bar paints with. Keys are pi theme colors; the Gentle themes // map them to the rose palette (accent = rose, syntaxFunction = powder blue). const ROLE = { BRAND: "accent", SEPARATOR: "dim", PATH: "muted", BRANCH: "text", DIRTY: "warning", MODEL: "text", EFFORT: "syntaxFunction", LABEL: "muted", VALUE: "text", STATUS: "muted", SESSION: "dim", } as const; export const SHELL_BAR_BRAND = "✿ gentle shell"; export const SHELL_BAR_SEPARATOR = "⟡"; export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS; const RIGHT_PADDING = 2; const COMPACT_BRANCH_WIDTH = 15; export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean { if (env.GENTLE_PI_AGENTS_CHILD === "1") return false; const value = env.GENTLE_PI_SHELL?.trim().toLowerCase(); return !(value === "0" || value === "false" || value === "off"); } // Herdr's native Pi detection matches Pi's standard loader row ("⠋ Working..."). // Inside a Herdr pane keep that row, which Pi shows only during real activity, // so detection works without a local manifest override. export function keepNativeWorkingRow(env: NodeJS.ProcessEnv = process.env): boolean { return env.HERDR_ENV === "1" && env.GENTLE_PI_AGENTS_CHILD !== "1"; } export function formatTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; if (count < 1_000_000) return `${Math.round(count / 1000)}k`; if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`; return `${Math.round(count / 1_000_000)}M`; } export function formatCost(total: number, subscription = false): string { const amount = total >= 1 ? total.toFixed(2) : total.toFixed(3); return subscription ? `$${amount} sub` : `$${amount}`; } // Extensions may paint their status themselves (pi-mcp-adapter does); the bar // owns the palette, so their escapes go and the text takes the status role. function sanitizeStatus(text: string): string { return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim(); } // Shared by the compact bar, the sidebar Status card, and the fullscreen // header row, so the three surfaces never drift on how they paint the same // facts. function locationSegment(model: Pick, theme: ShellBarTheme): string { const dirty = model.dirty ? ` ${theme.fg(ROLE.DIRTY, `±${model.dirty}`)}` : ""; return model.branch ? `${theme.fg(ROLE.PATH, model.cwd)} ${theme.fg(ROLE.BRANCH, model.branch)}${dirty}` : theme.fg(ROLE.PATH, model.cwd) + dirty; } function executionSegment(modelId: string, effort: string | undefined, theme: ShellBarTheme): string { return effort ? `${theme.fg(ROLE.MODEL, modelId)} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.EFFORT, effort)}` : theme.fg(ROLE.MODEL, modelId); } function contextSegment(contextPercent: number | null, theme: ShellBarTheme, paint?: GaugePaint): string { const percentText = contextPercent === null ? "?%" : `${Math.round(contextPercent)}%`; return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme, GAUGE_CELLS, paint)} ${theme.fg(ROLE.VALUE, percentText)}`; } function costSegment(costTotal: number, subscription: boolean, theme: ShellBarTheme): string { return theme.fg(ROLE.VALUE, formatCost(costTotal, subscription)); } function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation, paint?: GaugePaint): string[] { const location = locationSegment(model, theme); const modelSegment = executionSegment(model.modelId, model.effort, theme); const context = contextSegment(model.contextPercent, theme, paint); const cost = costSegment(model.costTotal, model.subscription, theme); const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId, paint) : undefined; const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status))); return [ ...(presentation?.density === "minimal" ? [] : [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND)]), location, ...(presentation?.visibility.modelDetails === false ? [] : [modelSegment]), ...(presentation?.visibility.usageCost === false ? [] : [context, cost, ...(usage ? [usage] : [])]), ...(model.jobs?.length ? [theme.fg(ROLE.STATUS, shellJobsCount(model.jobs.length))] : []), ...statuses, ]; } // When the line overflows, the location gives way first: the path shrinks to // its last segment and a long branch is clipped, so the trailing statuses // (MCP servers, extension notices) survive on ordinary terminal widths. function compactModel(model: ShellBarModel): ShellBarModel { const cwd = model.cwd.split("/").filter((part) => part.length > 0).pop() ?? model.cwd; const branch = model.branch && visibleWidth(model.branch) > COMPACT_BRANCH_WIDTH ? clipText(model.branch, COMPACT_BRANCH_WIDTH) : model.branch; return { ...model, cwd, branch }; } // Plain clip: pi's truncateToWidth wraps the result in resets, which would end // up inside a painted segment. function clipText(text: string, max: number): string { let clipped = ""; for (const char of text) { if (visibleWidth(clipped + char) > max - 1) break; clipped += char; } return `${clipped}…`; } function joinSegments(segments: string[], theme: ShellBarTheme): string { return segments.join(` ${theme.fg(ROLE.SEPARATOR, SHELL_BAR_SEPARATOR)} `); } // Sidebar groups use structured fields, never positional compact-bar segments // or inferred meanings from opaque extension status strings. export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation): string[] { const value = (text: string) => theme.fg(ROLE.VALUE, theme.bold(text)); const label = (text: string) => theme.fg(ROLE.LABEL, text); const changes = model.changes; const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : ""; // Pre-wrap values before indenting so Unicode/ANSI continuation lines keep // the same inset without consuming the card's right border. const innerWidth = panelInnerWidth(theme, width); const inset = Math.min(1, innerWidth - 1); // Model, effort, context, cost, and the per-model usage table now live in // the always-visible header row (and /gentle:usage for the full table); // this event-driven card keeps only what a footer/model-switch event does // not already refresh every frame. const groups: Array<{ title: string; lines: string[] }> = [ { title: "Project", lines: [ value(model.cwd), ...(branch ? [branch] : []), ...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []), ...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []), ], }, ...(presentation?.visibility.changes === false ? [] : [{ title: "Changes", lines: [ changes?.files ? `${changes.files} ${changes.files === 1 ? "file" : "files"} · ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}` : label("No captured changes"), label("/gentle:changes"), ], }]), ...(model.review && presentation?.visibility.rdd !== false ? [{ title: "🌹 RDD", lines: [ value(REVIEW_SIDEBAR_LABELS[model.review.state]), // Unknown scope is an internal sentinel, not something the user acts on. ...(model.review.scope !== REVIEW_SCOPE_UNAVAILABLE ? [label(sanitizeStatus(model.review.scope))] : []), ], }] : []), // Running jobs live in their own sidebar card (extensions/gentle-shell.ts // registers the "jobs" rail part); the compact bar keeps the count. { title: "Integrations", lines: model.statuses.length ? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status))) : [label("No status reported")] }, ]; // Wrap and indent every group line before it reaches the card, so Unicode and // ANSI continuation lines keep the same inset without consuming the right border. const body = groups.flatMap((group, index) => [ ...(index && (!presentation || presentation.density === "comfortable") ? [""] : []), ...(presentation?.density === "minimal" ? [] : [label(group.title)]), ...group.lines.flatMap((line) => wrapTextWithAnsi(line, innerWidth - inset).map((part) => " ".repeat(inset) + part)), ]); return renderCard({ title: "Status", body, tone: CARD_TONE.INFO }, theme, width, { expanded: true, panel: true }); } const HEADER_BRAND = "✿ Gentle Shell"; // Narrower than the width, widest first: dropping the profile, then the // effort, then the whole location keeps the brand and the bare model id // alive as long as anything can still share the row with the right-aligned // counters. function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme, showModelDetails: boolean): string[][] { const brand = theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); const location = locationSegment(model, theme); if (!showModelDetails) return [[brand, location], [brand]]; const withEffort = executionSegment(model.modelId, model.effort, theme); const modelOnly = executionSegment(model.modelId, undefined, theme); const withProfile = model.profile ? `${withEffort} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.MODEL, sanitizeStatus(model.profile))}` : withEffort; return [ [brand, location, withProfile], [brand, location, withEffort], [brand, location, modelOnly], [brand, modelOnly], [brand], ]; } const USAGE_LABEL_ROLE = ROLE.LABEL; const USAGE_HINT_ROLE = "dim"; function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean, paint?: GaugePaint): string { const percent = `${Math.round(window.usedPercent)}%`; const parts = [ ...(window.label.length > 0 ? [theme.fg(ROLE.LABEL, window.label)] : []), ...(withGauge ? [paintGauge(window.usedPercent, theme, GAUGE_CELLS, paint)] : []), theme.fg(ROLE.VALUE, percent), ]; return parts.join(" "); } // Three degrading shapes for the same windows, narrowest last: every window // with its gauge, every window as text only, or just the first window as // text only. A provider with no usage data at all has no windows to shape, // so all three collapse to the bare "usage" label plus the shortcut hint. type UsageStage = "full" | "text" | "primary"; function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined, paint?: GaugePaint): string { const label = theme.fg(USAGE_LABEL_ROLE, "usage"); const shown = stage === "primary" ? windows.slice(0, 1) : windows; const body = shown.map((window) => usageWindowText(window, theme, stage === "full", paint)).join(` ${theme.fg(ROLE.LABEL, "·")} `); const head = body.length > 0 ? `${label} ${body}` : label; return hint ? `${head} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(USAGE_HINT_ROLE, hint)}` : head; } // The float header is a full-width INFO background bar with a two-column // inset on each side, closed below by the header edge line. floatRows paints // the background inside one-column transparent margins; those margin cells // are painted too, so the bar reaches both edges. Undefined for neon, a width // under the float minimum, or a theme without a background. function floatHeaderRow(theme: ShellBarTheme, width: number, content: (width: number) => string): string | undefined { const painted = floatHeaderPaint(theme, width, content); if (painted === undefined) return undefined; const { inner, open } = painted; // The background spans the full width: the margin cells are painted too, so // the bar reaches both edges with no frame on its sides. const side = `${open} `; return `${side}${inner.slice(open.length, -BG_RESET.length)}${side}${BG_RESET}`; } function fitHeaderContent(text: string, width: number): string { const clipped = visibleWidth(text) <= width ? text : truncateToWidth(text, width, "…"); return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped))); } export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult { const targetWidth = Math.max(0, Math.floor(width)); let content: ShellHeaderResult | undefined; let contentWidth = targetWidth; const row = floatHeaderRow(theme, targetWidth, (inner) => { contentWidth = inner; content = headerContent(model, theme, inner, usageHint, presentation, paint); return content.text; }); if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation, paint); const { usageSpan } = content; // The inset splits evenly around the content, so its left share is where // the content's columns start. const offset = (targetWidth - contentWidth) / 2; return usageSpan ? { text: row, usageSpan: { start: usageSpan.start + offset, end: usageSpan.end + offset } } : { text: row }; } /** Shared geometry for the rail header and the below-editor header widget. */ export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, paint?: GaugePaint): ShellHeaderChrome { const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation, paint); const targetWidth = Math.max(0, Math.floor(width)); const painted = floatHeaderPaint(theme, targetWidth, () => ""); const rule = renderShellHeaderRule(theme, targetWidth); if (painted === undefined) return { rows: [text, rule], headerRow: 0, usageSpan }; const padding = `${painted.open}${" ".repeat(targetWidth)}${BG_RESET}`; if (presentation?.headerPlacement === "below-input") { // A lower one-eighth block hugs the painted padding in the next row. const upperEdge = theme.fg(HEADER_RULE_ROLE, "▁".repeat(targetWidth)); return { rows: [upperEdge, padding, text, padding], headerRow: 2, usageSpan }; } return { rows: [padding, text, padding, rule], headerRow: 1, usageSpan }; } /** The usage segment is interactive only on the content row, never padding or the edge. */ export function shellHeaderUsageHit(chrome: ShellHeaderChrome, x: number, y: number): boolean { const { usageSpan } = chrome; return y === chrome.headerRow && usageSpan !== undefined && x >= usageSpan.start && x < usageSpan.end; } function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult { const targetWidth = Math.max(0, Math.floor(width)); const jobs = model.jobs ? theme.fg(ROLE.STATUS, shellJobsCount(model.jobs)) : undefined; const ctxCost = joinSegments([ ...(jobs ? [jobs] : []), ...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme, paint), costSegment(model.costTotal, model.subscription, theme)]), ], theme); const windows = model.usage ? (selectUsageLimit(model.usage, model.modelId)?.windows ?? []) : []; const leftStages = headerLeftStages(model, theme, presentation?.visibility.modelDetails !== false) .map((stage) => presentation?.density === "minimal" ? stage.slice(1) : stage); const minimalLeft = Math.max(0, leftStages.length - 2); // brand + bare model id, before dropping the model too // One flat, ordered cascade — never a per-stage nested search — so the // left group fully degrades (profile → effort → location) before usage // ever gives anything up, and usage fully degrades (gauges → secondary // windows → the whole segment) before ctx/cost is touched: every window // with its gauge, then text-only, then the first window only, then gone. // Unlike the compact bar's usage meter (hidden with no data), this is a // standing, clickable affordance — even with nothing to report it still // reads "usage" plus the shortcut hint, unless disabled, width permitting. const usageStages: Array = presentation?.visibility.usageCost === false ? [undefined] : ["full", "text", "primary", undefined]; const attempts: Array<{ leftIndex: number; usageStage: UsageStage | undefined }> = [ ...leftStages.slice(0, minimalLeft).map((_, leftIndex) => ({ leftIndex, usageStage: usageStages[0] })), ...usageStages.map((usageStage) => ({ leftIndex: minimalLeft, usageStage })), { leftIndex: leftStages.length - 1, usageStage: undefined }, ]; for (const { leftIndex, usageStage } of attempts) { const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint, paint) : undefined; const right = usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost; const left = joinSegments(leftStages[leftIndex]!, theme); if (visibleWidth(left) + (right ? RIGHT_PADDING : 0) + visibleWidth(right) > targetWidth) continue; const text = left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(right)) + right; if (!usageText) return { text }; const usageStart = visibleWidth(left) + (targetWidth - visibleWidth(left) - visibleWidth(right)) + visibleWidth(ctxCost) + visibleWidth(` ${SHELL_BAR_SEPARATOR} `); return { text, usageSpan: { start: usageStart, end: usageStart + visibleWidth(usageText) } }; } const brand = presentation?.density === "minimal" ? "" : theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); if (jobs && visibleWidth(jobs) <= targetWidth) { const left = visibleWidth(brand) + RIGHT_PADDING + visibleWidth(jobs) <= targetWidth ? brand : ""; return { text: left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(jobs)) + jobs }; } return { text: visibleWidth(brand) <= targetWidth ? brand : "" }; } // A single owner supplies real captured filenames, not ShellBarModel's totals. // Undefined keeps every legacy surface alive for style/width/theme transitions. export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): ShellHeaderChrome | undefined { if (presentation?.headerPlacement !== "below-input") return undefined; const targetWidth = Math.max(0, Math.floor(width)); if (floatHeaderPaint(theme, targetWidth, () => "") === undefined) return undefined; const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation, paint); // The row exists only while files are actually tracked; an unborn-only root // is silence here (/gentle:changes explains the state on demand). const changesRow = Boolean(changes && changes.files.length > 0) && presentation.visibility.changes !== false ? floatHeaderRow(theme, targetWidth, (inner) => renderChangesWidget(changes, theme, inner)[0] ?? "") : undefined; const statuses = presentation.statusPlacement === "hidden" ? [] : [ ...(model.jobs?.length ? [shellJobsCount(model.jobs.length)] : []), ...model.statuses.map(sanitizeStatus).filter(Boolean), ]; const statusRow = statuses.length ? floatHeaderRow(theme, targetWidth, () => joinSegments(statuses.map((status) => theme.fg(ROLE.STATUS, status)), theme)) : undefined; return { rows: [ chrome.rows[0]!, chrome.rows[1]!, ...(changesRow ? [changesRow] : []), chrome.rows[2]!, ...(statusRow ? [statusRow] : []), chrome.rows[3]!, ], headerRow: changesRow ? 3 : 2, usageSpan: chrome.usageSpan, }; } // The bottom bar when it is the only status row of a narrow fullscreen // terminal (the header sits below the input and steps aside). It reuses the // header row's own cascade, so context, cost and usage outlive the location // on small screens, and keeps extension statuses on a second line instead of // dropping them the way the header deliberately does. export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): string[] { const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes, paint); if (grouped) return grouped.rows; const headerModel = buildShellHeaderModel(model, true); // Neon, missing backgrounds and sub-minimum widths keep the old row count. const rows = [headerContent(headerModel, theme, width, usageHint, presentation, paint).text]; const statuses = model.statuses.map(sanitizeStatus).filter((status) => status.length > 0).map((status) => theme.fg(ROLE.STATUS, status)); return statuses.length ? [...rows, truncateToWidth(joinSegments(statuses, theme), Math.max(0, Math.floor(width)), "…")] : rows; } // The rule row painted directly under the header bar: one full-width horizontal // line in the same theme role as the editor frame (PROMPT_FRAME_ROLE in // extensions/gentle-shell.ts), so the status row and the prompt read as one // panel. It exists only while the fullscreen sidebar is active — when the // sidebar is not shown the header rail never renders and the rule goes away // with it. const HEADER_RULE_CHAR = "─"; const HEADER_RULE_ROLE = "border"; const HEADER_EDGE_CHAR = "▔"; const BG_RESET = "\x1b[49m"; // Paints the header content with floatRows and returns the row without its // transparent margins plus the background opener. The painted row is // ` `; the opener has no spaces, so the first space ends // it. Undefined when the float style does not apply. function floatHeaderPaint(theme: ShellBarTheme, width: number, content: (width: number) => string): { inner: string; open: string } | undefined { let floated = false; const [row] = floatRows(CARD_TONE.INFO, theme, width, (inner) => { floated = inner !== width; return floated ? { body: [` ${fitHeaderContent(content(inner - 2), inner - 2)} `] } : {}; }); if (!floated || row === undefined) return undefined; const inner = row.slice(1, -1); return { inner, open: inner.slice(0, inner.indexOf(" ")) }; } // In the float style the rule closes the hanging header tab with an upper // one-eighth block line. Box-drawing lines sit mid-cell: a transparent `└──┘` // leaves half an unpainted row under the tab, and a painted one overshoots it. // `▔` hugs the top of its cell, so the line touches the tab's background // exactly. The edge stays transparent beneath the painted bottom padding. export function renderShellHeaderRule(theme: ShellBarTheme, width: number): string { const targetWidth = Math.max(0, Math.floor(width)); if (floatHeaderPaint(theme, targetWidth, () => "") !== undefined) return theme.fg(HEADER_RULE_ROLE, HEADER_EDGE_CHAR.repeat(targetWidth)); return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(targetWidth)); } export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation, paint?: GaugePaint): string[] { let segments = buildSegments(model, theme, presentation, paint); const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined; let left = joinSegments(segments, theme); if (right && visibleWidth(left) + RIGHT_PADDING + visibleWidth(right) <= width) { const padding = " ".repeat(width - visibleWidth(left) - visibleWidth(right)); return [left + padding + right]; } if (visibleWidth(left) > width) { segments = buildSegments(compactModel(model), theme, presentation); left = joinSegments(segments, theme); } while (segments.length > 1 && visibleWidth(left) > width) { segments.pop(); left = joinSegments(segments, theme); } return [truncateToWidth(left, width, "…")]; }