diff --git a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts index 23bba623d..8d0cdaa49 100644 --- a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +++ b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts @@ -42,13 +42,27 @@ function createHost() { }, }; + const pinnedMessageContainer = { + children: [] as any[], + addChild(component: any) { + this.children.push(component); + }, + removeChild(component: any) { + const idx = this.children.indexOf(component); + if (idx !== -1) this.children.splice(idx, 1); + }, + clear() { + this.children = []; + }, + }; + const host: any = { isInitialized: true, init: async () => {}, defaultEditor: { onEscape: undefined }, editor: {}, session: { retryAttempt: 0, abortCompaction: () => {}, abortRetry: () => {} }, - ui: { requestRender: () => {} }, + ui: { requestRender: () => {}, terminal: { rows: 50 } }, footer: { invalidate: () => {} }, keybindings: {}, statusContainer: { clear: () => {}, addChild: () => {} }, @@ -62,6 +76,7 @@ function createHost() { compactionQueuedMessages: [], editorContainer: {}, pendingMessagesContainer: { clear: () => {} }, + pinnedMessageContainer, addMessageToChat: () => {}, getMarkdownThemeWithSettings: () => ({}), formatWebSearchResult: () => "", @@ -218,3 +233,138 @@ test("chat-controller keeps serverToolUse output ahead of assistant text when ex assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent"); assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent"); }); + +test("chat-controller pins latest assistant text above editor when tool calls are present", async () => { + (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = { + fg: (_key: string, text: string) => text, + bg: (_key: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + truncate: (text: string) => text, + }; + + const host = createHost(); + const toolId = "tool-pin-1"; + const toolCall = { + type: "toolCall", + id: toolId, + name: "exec_command", + arguments: { cmd: "echo hi" }, + }; + + await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any); + + assert.equal(host.pinnedMessageContainer.children.length, 0, "pinned zone should be empty at message_start"); + + // Send a message with text followed by a tool call + host.getMarkdownThemeWithSettings = () => ({}); + await handleAgentEvent( + host, + { + type: "message_update", + message: makeAssistant([ + { type: "text", text: "Looking at the files now." }, + toolCall, + ]), + assistantMessageEvent: { + type: "toolcall_end", + contentIndex: 1, + toolCall: { + ...toolCall, + externalResult: { + content: [{ type: "text", text: "file contents" }], + details: {}, + isError: false, + }, + }, + partial: makeAssistant([{ type: "text", text: "Looking at the files now." }, toolCall]), + }, + } as any, + ); + + // Pinned zone should now have a DynamicBorder and a Markdown component + assert.equal(host.pinnedMessageContainer.children.length, 2, "pinned zone should have border + markdown"); + assert.equal(host.pinnedMessageContainer.children[0]?.constructor?.name, "DynamicBorder"); + assert.equal(host.pinnedMessageContainer.children[1]?.constructor?.name, "Markdown"); +}); + +test("chat-controller clears pinned zone when a new assistant message starts", async () => { + (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = { + fg: (_key: string, text: string) => text, + bg: (_key: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + truncate: (text: string) => text, + }; + + const host = createHost(); + const toolCall = { + type: "toolCall", + id: "tool-clear-1", + name: "exec_command", + arguments: { cmd: "echo hi" }, + }; + + await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any); + + // Populate the pinned zone + host.getMarkdownThemeWithSettings = () => ({}); + await handleAgentEvent( + host, + { + type: "message_update", + message: makeAssistant([{ type: "text", text: "Working on it." }, toolCall]), + assistantMessageEvent: { + type: "toolcall_end", + contentIndex: 1, + toolCall: { + ...toolCall, + externalResult: { + content: [{ type: "text", text: "ok" }], + details: {}, + isError: false, + }, + }, + partial: makeAssistant([{ type: "text", text: "Working on it." }, toolCall]), + }, + } as any, + ); + + assert.ok(host.pinnedMessageContainer.children.length > 0, "pinned zone should be populated"); + + // Start a new assistant message — pinned zone should clear + await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any); + + assert.equal(host.pinnedMessageContainer.children.length, 0, "pinned zone should clear on new assistant message"); +}); + +test("chat-controller does not pin when there are no tool calls", async () => { + (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = { + fg: (_key: string, text: string) => text, + bg: (_key: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + truncate: (text: string) => text, + }; + + const host = createHost(); + + await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any); + + host.getMarkdownThemeWithSettings = () => ({}); + await handleAgentEvent( + host, + { + type: "message_update", + message: makeAssistant([{ type: "text", text: "Just some text, no tools." }]), + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "Just some text, no tools.", + partial: makeAssistant([{ type: "text", text: "Just some text, no tools." }]), + }, + } as any, + ); + + assert.equal(host.pinnedMessageContainer.children.length, 0, "pinned zone should stay empty without tool calls"); +}); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts index a54298065..5a023afd3 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -1,8 +1,10 @@ -import type { Component } from "@gsd/pi-tui"; +import type { Component, TUI } from "@gsd/pi-tui"; +import { visibleWidth } from "@gsd/pi-tui"; import { theme } from "../theme/theme.js"; /** * Dynamic border component that adjusts to viewport width. + * Supports an optional animated spinner in the label area. * * Note: When used from extensions loaded via jiti, the global `theme` may be undefined * because jiti creates a separate module cache. Always pass an explicit color @@ -10,11 +12,51 @@ import { theme } from "../theme/theme.js"; */ export class DynamicBorder implements Component { private color: (str: string) => string; + private label?: string; + private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + private spinnerIndex = 0; + private spinnerInterval: NodeJS.Timeout | null = null; + private spinnerColorFn?: (str: string) => string; constructor(color: (str: string) => string = (str) => { try { return theme.fg("border", str); } catch { return str; } - }) { + }, label?: string) { this.color = color; + this.label = label; + } + + setLabel(label: string | undefined): void { + this.label = label; + } + + /** + * Start an animated spinner that prepends to the label. + * The spinner rotates every 80ms and triggers a re-render via the TUI. + */ + startSpinner(ui: TUI, colorFn: (str: string) => string): void { + this.stopSpinner(); + this.spinnerColorFn = colorFn; + this.spinnerIndex = 0; + this.spinnerInterval = setInterval(() => { + this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length; + ui.requestRender(); + }, 80); + ui.requestRender(); + } + + /** + * Stop the spinner animation. The border reverts to a static label. + */ + stopSpinner(): void { + if (this.spinnerInterval) { + clearInterval(this.spinnerInterval); + this.spinnerInterval = null; + } + this.spinnerColorFn = undefined; + } + + get isSpinning(): boolean { + return this.spinnerInterval !== null; } invalidate(): void { @@ -22,6 +64,20 @@ export class DynamicBorder implements Component { } render(width: number): string[] { + const spinnerPrefix = this.spinnerInterval && this.spinnerColorFn + ? this.spinnerColorFn(this.spinnerFrames[this.spinnerIndex]) + " " + : ""; + + if (this.label) { + const labelText = ` ${spinnerPrefix}${this.label} `; + const labelVisible = visibleWidth(labelText); + const leading = "── "; + const remaining = Math.max(0, width - labelVisible - leading.length); + const trailing = "─".repeat(Math.max(1, remaining)); + // Color leading and trailing separately so embedded ANSI in the + // spinner/label doesn't bleed into the trailing dashes. + return [this.color(leading) + labelText + this.color(trailing)]; + } return [this.color("─".repeat(Math.max(1, width)))]; } } diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index 168bdaa45..c7a7daf8b 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -1,9 +1,10 @@ -import { Loader, Spacer, Text } from "@gsd/pi-tui"; +import { Loader, Markdown, Spacer, Text } from "@gsd/pi-tui"; import type { InteractiveModeEvent, InteractiveModeStateHost } from "../interactive-mode-state.js"; import { theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "../components/assistant-message.js"; import { ToolExecutionComponent } from "../components/tool-execution.js"; +import { DynamicBorder } from "../components/dynamic-border.js"; import { appKey } from "../components/keybinding-hints.js"; // Tracks the last processed content index to avoid re-scanning all blocks on every message_update @@ -21,6 +22,15 @@ function hasAssistantToolBlocks(message: { content: Array }): boolean { return message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse"); } +// Tracks the latest assistant text for the pinned message zone +let lastPinnedText = ""; +// Whether any tool execution has been added in this assistant turn (triggers pinned display) +let hasToolsInTurn = false; +// Reference to the pinned border so we can toggle its label between working/idle +let pinnedBorder: DynamicBorder | undefined; +// Reference to the pinned markdown component below the border +let pinnedTextComponent: Markdown | undefined; + export async function handleAgentEvent(host: InteractiveModeStateHost & { init: () => Promise; getMarkdownThemeWithSettings: () => any; @@ -43,9 +53,15 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { host.footer.invalidate(); - // Reset content index tracker when a new assistant message starts + // Reset content index tracker and pinned state when a new assistant message starts if (event.type === "message_start" && event.message.role === "assistant") { lastProcessedContentIndex = 0; + lastPinnedText = ""; + hasToolsInTurn = false; + if (pinnedBorder) pinnedBorder.stopSpinner(); + pinnedBorder = undefined; + pinnedTextComponent = undefined; + host.pinnedMessageContainer.clear(); } switch (event.type) { @@ -58,6 +74,12 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { host.streamingMessage = undefined; host.pendingTools.clear(); host.pendingMessagesContainer.clear(); + host.pinnedMessageContainer.clear(); + lastPinnedText = ""; + hasToolsInTurn = false; + if (pinnedBorder) pinnedBorder.stopSpinner(); + pinnedBorder = undefined; + pinnedTextComponent = undefined; host.compactionQueuedMessages = []; host.rebuildChatFromMessages(); host.updatePendingMessagesDisplay(); @@ -255,6 +277,59 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { if (contentBlocks.length > 0) { lastProcessedContentIndex = Math.max(0, contentBlocks.length - 1); } + + // Pinned message: mirror the latest assistant text above the editor + // when tool executions push it out of the viewport. + const hasTools = contentBlocks.some( + (c: any) => c.type === "toolCall" || c.type === "serverToolUse", + ); + if (hasTools) hasToolsInTurn = true; + + if (hasToolsInTurn) { + // Collect the latest text block(s) from the assistant message + let latestText = ""; + for (let i = contentBlocks.length - 1; i >= 0; i--) { + const c = contentBlocks[i] as any; + if (c.type === "text" && c.text?.trim()) { + latestText = c.text.trim(); + break; + } + } + + if (latestText && latestText !== lastPinnedText) { + lastPinnedText = latestText; + + if (!pinnedBorder) { + // First time: create border + text component + host.pinnedMessageContainer.clear(); + pinnedBorder = new DynamicBorder( + (str: string) => theme.fg("dim", str), + "Working · Latest Output", + ); + pinnedBorder.startSpinner(host.ui, (str: string) => theme.fg("accent", str)); + host.pinnedMessageContainer.addChild(pinnedBorder); + pinnedTextComponent = new Markdown(latestText, 1, 0, host.getMarkdownThemeWithSettings()); + // Cap pinned content to ~40% of terminal height so tall output + // doesn't exceed the viewport and cause render flashing. + pinnedTextComponent.maxLines = Math.max(3, Math.floor(host.ui.terminal.rows * 0.4)); + host.pinnedMessageContainer.addChild(pinnedTextComponent); + // Hide the separate status loader — the pinned zone replaces it + if (host.loadingAnimation) { + host.loadingAnimation.stop(); + host.loadingAnimation = undefined; + } + host.statusContainer.clear(); + } else { + // Update existing markdown component in-place + pinnedTextComponent?.setText(latestText); + // Refresh maxLines in case terminal was resized + if (pinnedTextComponent) { + pinnedTextComponent.maxLines = Math.max(3, Math.floor(host.ui.terminal.rows * 0.4)); + } + } + } + } + host.ui.requestRender(); } break; @@ -357,6 +432,16 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { host.streamingMessage = undefined; } host.pendingTools.clear(); + // Stop spinner on pinned border and switch label from "Working · Latest Output" to "Latest Output" + if (pinnedBorder) { + pinnedBorder.stopSpinner(); + pinnedBorder.setLabel("Latest Output"); + } + // Keep pinned message visible until the next assistant turn starts. + lastPinnedText = ""; + hasToolsInTurn = false; + pinnedBorder = undefined; + pinnedTextComponent = undefined; await host.checkShutdownRequested(); host.ui.requestRender(); break; diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts index cf91b00b1..bffa82d51 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts @@ -9,6 +9,7 @@ export interface InteractiveModeStateHost { keybindings: any; statusContainer: any; chatContainer: any; + pinnedMessageContainer: any; settingsManager: any; pendingTools: Map; toolOutputExpanded: boolean; diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index eb062ca41..c42aca520 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -168,6 +168,7 @@ export class InteractiveMode { private chatContainer: Container; private pendingMessagesContainer: Container; private statusContainer: Container; + private pinnedMessageContainer: Container; private defaultEditor: CustomEditor; private editor: EditorComponent; private autocompleteProvider: CombinedAutocompleteProvider | undefined; @@ -285,6 +286,7 @@ export class InteractiveMode { this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); + this.pinnedMessageContainer = new Container(); this.widgetContainerAbove = new Container(); this.widgetContainerBelow = new Container(); this.keybindings = KeybindingsManager.create(); @@ -490,6 +492,7 @@ export class InteractiveMode { this.ui.addChild(this.chatContainer); this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); + this.ui.addChild(this.pinnedMessageContainer); this.renderWidgets(); // Initialize with default spacer this.ui.addChild(this.widgetContainerAbove); this.ui.addChild(this.editorContainer); @@ -1396,7 +1399,19 @@ export class InteractiveMode { */ private renderWidgets(): void { if (!this.widgetContainerAbove || !this.widgetContainerBelow) return; - this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true); + + // widgetContainerAbove: spacer collapses when pinned content is visible + // so there's no extra blank line between pinned output and the editor border. + this.widgetContainerAbove.clear(); + const pinned = this.pinnedMessageContainer; + this.widgetContainerAbove.addChild({ + render: () => pinned.children.length > 0 ? [] : [""], + invalidate: () => {}, + }); + for (const component of this.extensionWidgetsAbove.values()) { + this.widgetContainerAbove.addChild(component); + } + this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false); this.ui.requestRender(); } @@ -2264,6 +2279,7 @@ export class InteractiveMode { updateFooter: true, populateHistory: true, }); + this.populatePinnedFromMessages(context.messages); // Show compaction info if session was compacted const allEntries = this.sessionManager.getEntries(); @@ -2287,6 +2303,54 @@ export class InteractiveMode { this.chatContainer.clear(); const context = this.sessionManager.buildSessionContext(); this.renderSessionContext(context); + this.populatePinnedFromMessages(context.messages); + } + + /** + * After rebuilding chat from messages, pin the last assistant text above the + * editor if tool results would otherwise push it out of the viewport. + */ + private populatePinnedFromMessages(messages: AgentMessage[]): void { + this.pinnedMessageContainer.clear(); + + // Walk backwards to find the last assistant message + let lastAssistant: AssistantMessage | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg && "role" in msg && msg.role === "assistant") { + lastAssistant = msg as AssistantMessage; + break; + } + } + if (!lastAssistant) return; + + // Check if any tool calls follow the last text block + const content = lastAssistant.content; + let lastTextIndex = -1; + let hasToolAfterText = false; + for (let i = 0; i < content.length; i++) { + if (content[i].type === "text") lastTextIndex = i; + } + if (lastTextIndex >= 0) { + for (let i = lastTextIndex + 1; i < content.length; i++) { + if (content[i].type === "toolCall" || content[i].type === "serverToolUse") { + hasToolAfterText = true; + break; + } + } + } + if (!hasToolAfterText || lastTextIndex < 0) return; + + const textBlock = content[lastTextIndex] as { type: "text"; text: string }; + const text = textBlock.text?.trim(); + if (!text) return; + + this.pinnedMessageContainer.addChild( + new DynamicBorder((str: string) => theme.fg("dim", str), "Latest Output"), + ); + this.pinnedMessageContainer.addChild( + new Markdown(text, 1, 0, this.getMarkdownThemeWithSettings()), + ); } // ========================================================================= diff --git a/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts b/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts new file mode 100644 index 000000000..fb9fbf0bc --- /dev/null +++ b/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Markdown, type MarkdownTheme } from "../markdown.js"; + +function noopTheme(): MarkdownTheme { + const identity = (text: string) => text; + return { + heading: identity, + link: identity, + linkUrl: identity, + code: identity, + codeBlock: identity, + codeBlockBorder: identity, + quote: identity, + quoteBorder: identity, + hr: identity, + listBullet: identity, + bold: identity, + italic: identity, + strikethrough: identity, + underline: identity, + }; +} + +test("Markdown renders all lines when maxLines is not set", () => { + const text = "Line 1\n\nLine 2\n\nLine 3\n\nLine 4\n\nLine 5"; + const md = new Markdown(text, 0, 0, noopTheme()); + const lines = md.render(80); + // Each paragraph produces a line + an inter-paragraph blank line + const contentLines = lines.filter((l) => l.trim().length > 0); + assert.ok(contentLines.length >= 5, `expected at least 5 content lines, got ${contentLines.length}`); +}); + +test("Markdown truncates from the top when maxLines is exceeded", () => { + const text = "Line 1\n\nLine 2\n\nLine 3\n\nLine 4\n\nLine 5"; + const md = new Markdown(text, 0, 0, noopTheme()); + md.maxLines = 3; + const lines = md.render(80); + assert.ok(lines.length <= 3, `expected at most 3 lines, got ${lines.length}`); + // First line should be the ellipsis indicator + assert.ok(lines[0].includes("…"), "first line should contain ellipsis indicator"); + assert.ok(lines[0].includes("above"), "first line should mention lines above"); +}); + +test("Markdown preserves most recent content when truncating", () => { + const text = "First paragraph\n\nSecond paragraph\n\nThird paragraph\n\nFourth paragraph\n\nFifth paragraph"; + const md = new Markdown(text, 0, 0, noopTheme()); + md.maxLines = 3; + const lines = md.render(80); + // The last rendered line should contain "Fifth paragraph" (the most recent content) + const lastContentLine = lines.filter((l) => !l.includes("…")).pop() ?? ""; + assert.ok( + lastContentLine.includes("Fifth paragraph"), + `expected last content line to contain "Fifth paragraph", got "${lastContentLine}"`, + ); +}); + +test("Markdown does not truncate when content fits within maxLines", () => { + const text = "Short text"; + const md = new Markdown(text, 0, 0, noopTheme()); + md.maxLines = 10; + const lines = md.render(80); + assert.ok(!lines.some((l) => l.includes("…")), "should not contain ellipsis when content fits"); + assert.ok(lines.some((l) => l.includes("Short text")), "should contain the original text"); +}); + +test("Markdown trims trailing empty lines", () => { + const text = "Some text\n\n"; + const md = new Markdown(text, 0, 0, noopTheme()); + const lines = md.render(80); + // Last line should not be empty (trailing empties are trimmed) + const lastLine = lines[lines.length - 1]; + assert.ok(lastLine.trim().length > 0 || lines.length === 1, "trailing empty lines should be trimmed"); +}); diff --git a/packages/pi-tui/src/components/markdown.ts b/packages/pi-tui/src/components/markdown.ts index 0920e6b4f..e1d7d454f 100644 --- a/packages/pi-tui/src/components/markdown.ts +++ b/packages/pi-tui/src/components/markdown.ts @@ -58,10 +58,13 @@ export class Markdown implements Component { private defaultTextStyle?: DefaultTextStyle; private theme: MarkdownTheme; private defaultStylePrefix?: string; + /** Maximum rendered lines (excluding padding). When set, content is truncated from the top with an ellipsis indicator so the most recent output remains visible. */ + maxLines?: number; // Cache for rendered output private cachedText?: string; private cachedWidth?: number; + private cachedMaxLines?: number; private cachedLines?: string[]; constructor( @@ -86,12 +89,13 @@ export class Markdown implements Component { invalidate(): void { this.cachedText = undefined; this.cachedWidth = undefined; + this.cachedMaxLines = undefined; this.cachedLines = undefined; } render(width: number): string[] { // Check cache - if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width && this.cachedMaxLines === this.maxLines) { return this.cachedLines; } @@ -104,6 +108,7 @@ export class Markdown implements Component { // Update cache this.cachedText = this.text; this.cachedWidth = width; + this.cachedMaxLines = this.maxLines; this.cachedLines = result; return result; } @@ -124,6 +129,12 @@ export class Markdown implements Component { for (let j = 0; j < tokenLines.length; j++) renderedLines.push(tokenLines[j]); } + // Trim trailing empty lines — inter-block spacing at the end just adds + // unwanted whitespace before whatever follows (e.g. pinned output border). + while (renderedLines.length > 0 && renderedLines[renderedLines.length - 1] === "") { + renderedLines.pop(); + } + // Wrap lines (NO padding, NO background yet) const wrappedLines: string[] = []; for (const line of renderedLines) { @@ -143,6 +154,15 @@ export class Markdown implements Component { } } + // Truncate from the top when maxLines is set so the most recent content + // stays visible. This prevents the pinned output zone from exceeding the + // terminal height and causing render flashing. + if (this.maxLines !== undefined && wrappedLines.length > this.maxLines) { + const keep = Math.max(1, this.maxLines - 1); // Reserve one line for the ellipsis indicator + const truncated = wrappedLines.length - keep; + wrappedLines.splice(0, truncated, `… ${truncated} line${truncated !== 1 ? "s" : ""} above`); + } + // Add margins and background to each wrapped line const leftMargin = " ".repeat(this.paddingX); const rightMargin = " ".repeat(this.paddingX); @@ -181,6 +201,7 @@ export class Markdown implements Component { // Update cache this.cachedText = this.text; this.cachedWidth = width; + this.cachedMaxLines = this.maxLines; this.cachedLines = result; return result.length > 0 ? result : [""];