fix(tui): restore pinned output above editor during tool execution
Restores the pinned assistant output zone that shows the latest narration during tool execution. Adds markdown rendering, animated spinner, height capping to prevent render flashing, and session rebuild support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ad61c43907
commit
1d1e47e78b
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