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:
Jeremy McSpadden 2026-04-11 15:39:50 -05:00 committed by GitHub
commit 9b1a44aa61
7 changed files with 459 additions and 7 deletions

View file

@ -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");
});

View file

@ -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)))];
}
}

View file

@ -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;

View file

@ -9,6 +9,7 @@ export interface InteractiveModeStateHost {
keybindings: any;
statusContainer: any;
chatContainer: any;
pinnedMessageContainer: any;
settingsManager: any;
pendingTools: Map<string, any>;
toolOutputExpanded: boolean;

View file

@ -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()),
);
}
// =========================================================================

View file

@ -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");
});

View file

@ -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 : [""];