Merge pull request #4003 from Git-Scram/fix/tui-pinned-output
fix(tui): restore pinned output above editor during tool execution
This commit is contained in:
commit
9b1a44aa61
7 changed files with 459 additions and 7 deletions
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)))];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<any> }): 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<void>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface InteractiveModeStateHost {
|
|||
keybindings: any;
|
||||
statusContainer: any;
|
||||
chatContainer: any;
|
||||
pinnedMessageContainer: any;
|
||||
settingsManager: any;
|
||||
pendingTools: Map<string, any>;
|
||||
toolOutputExpanded: boolean;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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 : [""];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue