feat(tui): prettier Working... spinner (#8799)

This commit is contained in:
Cristina Poncela Cubeiro 2026-09-03 20:34:04 +02:00 • committed by GitHub
parent e44d75c20a
commit 1d9787c11f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 275 additions and 39 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Moved the streaming working indicator into the default editor border and matched its default spinner and label to the thinking-level border color. Custom editors retain the standalone indicator unless they opt in to embedding it.
### Fixed
- Fixed branch summaries failing when reasoning consumes the previous 2048-token output cap ([#8845](https://github.com/earendil-works/pi/issues/8845)).

View file

@ -152,7 +152,7 @@ The interface from top to bottom:
- **Startup header** - Shows shortcuts (`/hotkeys` for all), loaded AGENTS.md files, prompt templates, skills, and extensions
- **Messages** - Your messages, assistant responses, tool calls and results, notifications, errors, and extension UI
- **Editor** - Where you type; border color indicates thinking level
- **Editor** - Where you type; border color indicates thinking level and the border shows the streaming working indicator
- **Footer** - Working directory, session name, total token/cache usage (`↑` input, `↓` output, `R` cache read, `W` cache write, `CH` latest cache hit rate), cost, context usage, current model. Totals include assistant responses, usage reported by tools, and summary generation.
The editor can be temporarily replaced by other UI, like built-in `/settings` or custom UI from extensions (e.g., a Q&A tool that lets the user answer model questions in a structured format). [Extensions](#extensions) can also replace the editor, add widgets above/below it, a status line, custom footer, or overlays.

View file

@ -2829,6 +2829,7 @@ export default function (pi: ExtensionAPI) {
**Key points:**
- Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching)
- Call `super.handleInput(data)` for keys you don't handle
- Custom editors keep the standalone working row by default. Pass `{ embedWorkingStatus: true }` as the fourth `CustomEditor` constructor argument to use the built-in editor-border spinner instead.
- Factory receives `tui`, `theme`, and `keybindings` from the app
- Use `ctx.ui.getEditorComponent()` before `setEditorComponent()` to wrap the previously configured custom editor
- Pass `undefined` to restore default: `ctx.ui.setEditorComponent(undefined)`

View file

@ -930,6 +930,7 @@ export default function (pi: ExtensionAPI) {
- **Extend `CustomEditor`** (not base `Editor`) to get app keybindings (escape to abort, ctrl+d to exit, model switching, etc.)
- **Call `super.handleInput(data)`** for keys you don't handle
- **Working status**: custom editors keep the standalone working row by default. Pass `{ embedWorkingStatus: true }` as the fourth `CustomEditor` constructor argument to use the built-in editor-border spinner instead.
- **Factory pattern**: `setEditorComponent` receives a factory function that gets `tui`, `theme`, and `keybindings`
- **Pass `undefined`** to restore the default editor: `ctx.ui.setEditorComponent(undefined)`

View file

@ -372,6 +372,7 @@ export {
BranchSummaryMessageComponent,
CompactionSummaryMessageComponent,
CustomEditor,
type CustomEditorOptions,
CustomMessageComponent,
DynamicBorder,
ExtensionEditorComponent,

View file

@ -1,11 +1,19 @@
import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
import { Editor, type EditorOptions, type EditorTheme, type TUI, visibleWidth } from "@earendil-works/pi-tui";
import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts";
import type { WorkingStatusIndicator } from "./status-indicator.ts";
export type CustomEditorOptions = EditorOptions & {
/** Render the streaming working status in the editor's top border. */
embedWorkingStatus?: boolean;
};
/**
* Custom editor that handles app-level keybindings for coding-agent.
*/
export class CustomEditor extends Editor {
private keybindings: KeybindingsManager;
private workingStatusIndicator: WorkingStatusIndicator | undefined;
public readonly embedWorkingStatus: boolean;
public actionHandlers: Map<AppKeybinding, () => void> = new Map();
// Special handlers that can be dynamically replaced
@ -15,9 +23,59 @@ export class CustomEditor extends Editor {
/** Handler for extension-registered shortcuts. Returns true if handled. */
public onExtensionShortcut?: (data: string) => boolean;
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options?: EditorOptions) {
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options?: CustomEditorOptions) {
super(tui, theme, options);
this.keybindings = keybindings;
this.embedWorkingStatus = options?.embedWorkingStatus ?? false;
}
setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
this.workingStatusIndicator = indicator;
}
protected override renderTopBorder(width: number, hiddenLineCount: number): string {
if (!this.embedWorkingStatus || !this.workingStatusIndicator || width <= 0) {
return super.renderTopBorder(width, hiddenLineCount);
}
let status = this.workingStatusIndicator.renderInBorder(Math.max(1, width - 5));
let statusWidth = visibleWidth(status);
if (statusWidth === 0) return super.renderTopBorder(width, hiddenLineCount);
const overflowLabel = hiddenLineCount > 0 ? ` ↑ ${hiddenLineCount} more ` : undefined;
const overflowLabelWidth = overflowLabel ? visibleWidth(overflowLabel) : 0;
const overflowStart = Math.floor((width - overflowLabelWidth) / 2);
const canFitOverflow = () =>
overflowLabel !== undefined && overflowLabelWidth + 2 <= width && overflowStart - (3 + statusWidth + 1) >= 1;
if (overflowLabel && !canFitOverflow()) {
status = this.workingStatusIndicator.renderSpinnerInBorder(width);
statusWidth = visibleWidth(status);
}
if (canFitOverflow()) {
const leftBlockWidth = 3 + statusWidth + 1;
return (
this.borderColor("── ") +
status +
this.borderColor(
` ${"─".repeat(overflowStart - leftBlockWidth)}${overflowLabel}${"─".repeat(width - overflowStart - overflowLabelWidth)}`,
)
);
}
if (width >= statusWidth + 5) {
return this.borderColor("── ") + status + this.borderColor(` ${"─".repeat(width - statusWidth - 4)}`);
}
status = this.workingStatusIndicator.renderSpinnerInBorder(width);
statusWidth = visibleWidth(status);
const prefixWidth = Math.min(3, Math.max(0, width - statusWidth));
return (
this.borderColor("─".repeat(prefixWidth)) +
status +
this.borderColor("─".repeat(Math.max(0, width - prefixWidth - statusWidth)))
);
}
/**

View file

@ -5,7 +5,7 @@ export { BashExecutionComponent } from "./bash-execution.ts";
export { BorderedLoader } from "./bordered-loader.ts";
export { BranchSummaryMessageComponent } from "./branch-summary-message.ts";
export { CompactionSummaryMessageComponent } from "./compaction-summary-message.ts";
export { CustomEditor } from "./custom-editor.ts";
export { CustomEditor, type CustomEditorOptions } from "./custom-editor.ts";
export { CustomMessageComponent } from "./custom-message.ts";
export { DaxnutsComponent } from "./daxnuts.ts";
export { type RenderDiffOptions, renderDiff } from "./diff.ts";

View file

@ -1,4 +1,4 @@
import { type Component, Loader, type TUI } from "@earendil-works/pi-tui";
import { type Component, Loader, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
import type { WorkingIndicatorOptions } from "../../../core/extensions/index.ts";
import { theme } from "../theme/theme.ts";
import { CountdownTimer } from "./countdown-timer.ts";
@ -27,16 +27,25 @@ export class StatusIndicator extends Loader {
}
export class WorkingStatusIndicator extends StatusIndicator {
constructor(ui: TUI, message: string, indicator?: WorkingIndicatorOptions) {
constructor(ui: TUI, message: string, indicator?: WorkingIndicatorOptions, colorFn?: (text: string) => string) {
super(
"working",
ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
colorFn ?? ((text) => theme.fg("accent", text)),
colorFn ?? ((text) => theme.fg("muted", text)),
message,
indicator,
);
}
renderInBorder(width: number): string {
const line = super.render(width + 2)[1] ?? "";
return truncateToWidth(line.startsWith(" ") ? line.slice(1).trimEnd() : line.trimEnd(), width, "");
}
renderSpinnerInBorder(width: number): string {
return truncateToWidth(this.getRenderedIndicator(), width, "");
}
}
export class RetryStatusIndicator extends StatusIndicator {

View file

@ -180,6 +180,20 @@ interface Expandable {
setExpanded(expanded: boolean): void;
}
interface WorkingStatusEditor extends EditorComponent {
readonly embedWorkingStatus: boolean;
setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void;
}
function isWorkingStatusEditor(editor: EditorComponent): editor is WorkingStatusEditor {
return (
"embedWorkingStatus" in editor &&
editor.embedWorkingStatus === true &&
"setWorkingStatusIndicator" in editor &&
typeof editor.setWorkingStatusIndicator === "function"
);
}
function isExpandable(obj: unknown): obj is Expandable {
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
}
@ -389,11 +403,12 @@ export class InteractiveMode {
private onInputCallback?: (text: string) => void;
private pendingUserInputs: string[] = [];
private activeStatusIndicator: StatusIndicator | undefined = undefined;
private activeWorkingIndicatorEmbedded = false;
private readonly idleStatus = new IdleStatus();
private workingMessage: string | undefined = undefined;
private workingVisible = true;
private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined;
private readonly defaultWorkingMessage = "Working...";
private readonly defaultWorkingMessage = "Working";
private readonly defaultHiddenThinkingLabel = "Thinking...";
private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
@ -543,6 +558,7 @@ export class InteractiveMode {
this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, {
paddingX: editorPaddingX,
autocompleteMaxVisible,
embedWorkingStatus: true,
});
this.editor = this.defaultEditor;
this.editorContainer = new Container();
@ -2075,10 +2091,23 @@ export class InteractiveMode {
this.ui.requestRender();
}
private setEditorWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): boolean {
this.defaultEditor.setWorkingStatusIndicator(undefined);
if (!isWorkingStatusEditor(this.editor)) return false;
this.editor.setWorkingStatusIndicator(indicator);
return true;
}
private showStatusIndicator(indicator: StatusIndicator): void {
this.activeStatusIndicator?.dispose();
this.activeStatusIndicator = indicator;
this.activeWorkingIndicatorEmbedded = false;
this.statusContainer.clear();
this.setEditorWorkingStatusIndicator(undefined);
if (indicator instanceof WorkingStatusIndicator && this.setEditorWorkingStatusIndicator(indicator)) {
this.activeWorkingIndicatorEmbedded = true;
return;
}
this.statusContainer.addChild(indicator);
}
@ -2086,21 +2115,34 @@ export class InteractiveMode {
if (kind && this.activeStatusIndicator?.kind !== kind) {
return;
}
const hadActiveStatusIndicator = this.activeStatusIndicator !== undefined;
this.activeStatusIndicator?.dispose();
const clearedIndicator = this.activeStatusIndicator;
const clearedIndicatorWasEmbedded = clearedIndicator?.kind === "working" && this.activeWorkingIndicatorEmbedded;
clearedIndicator?.dispose();
this.activeStatusIndicator = undefined;
this.activeWorkingIndicatorEmbedded = false;
this.statusContainer.clear();
if (hadActiveStatusIndicator && this.options.tuiMode === "regular" && this.ui.getClearOnShrink()) {
this.setEditorWorkingStatusIndicator(undefined);
if (
clearedIndicator &&
!clearedIndicatorWasEmbedded &&
this.options.tuiMode === "regular" &&
this.ui.getClearOnShrink()
) {
this.statusContainer.addChild(this.idleStatus);
}
}
private showWorkingStatusIndicator(): void {
const colorFn = isWorkingStatusEditor(this.editor)
? (text: string) =>
(this.editor.borderColor ?? theme.getThinkingBorderColor(this.session.thinkingLevel || "off"))(text)
: undefined;
this.showStatusIndicator(
new WorkingStatusIndicator(
this.ui,
this.workingMessage ?? this.defaultWorkingMessage,
this.workingIndicatorOptions,
colorFn,
),
);
}
@ -2674,6 +2716,13 @@ export class InteractiveMode {
}
this.editorContainer.addChild(this.editor as Component);
if (this.activeStatusIndicator instanceof WorkingStatusIndicator) {
this.statusContainer.clear();
this.activeWorkingIndicatorEmbedded = this.setEditorWorkingStatusIndicator(this.activeStatusIndicator);
if (!this.activeWorkingIndicatorEmbedded) {
this.statusContainer.addChild(this.activeStatusIndicator);
}
}
this.ui.setFocus(this.editor as Component);
this.ui.requestRender();
}
@ -4120,6 +4169,9 @@ export class InteractiveMode {
const level = this.session.thinkingLevel || "off";
this.editor.borderColor = theme.getThinkingBorderColor(level);
}
if (this.activeStatusIndicator?.kind === "working") {
this.activeStatusIndicator.invalidate();
}
this.ui.requestRender();
}

View file

@ -312,38 +312,76 @@ describe("InteractiveMode copy confirmation", () => {
});
});
type StatusEditor = {
embedWorkingStatus: boolean;
setWorkingStatusIndicator: (indicator: undefined) => void;
};
type ClearStatusContext = {
activeStatusIndicator: { kind: "working"; dispose: () => void } | undefined;
activeStatusIndicator: { kind: "working" | "retry"; dispose: () => void } | undefined;
activeWorkingIndicatorEmbedded: boolean;
statusContainer: Container;
defaultEditor: StatusEditor;
editor: Partial<StatusEditor>;
options: { tuiMode?: TuiMode };
ui: { getClearOnShrink: () => boolean };
idleStatus: Component;
setEditorWorkingStatusIndicator(indicator: undefined): boolean;
};
type InteractiveModePrototype = {
clearStatusIndicator(this: ClearStatusContext, kind?: "working"): void;
clearStatusIndicator(this: ClearStatusContext, kind?: "working" | "retry"): void;
setEditorWorkingStatusIndicator(this: ClearStatusContext, indicator: undefined): boolean;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
describe("clear-on-shrink status spacing", () => {
it("reserves status height only on the main-screen renderer", () => {
it("does not reserve separate status height for the editor-border working indicator", () => {
const dispose = vi.fn();
const editor: StatusEditor = { embedWorkingStatus: true, setWorkingStatusIndicator: vi.fn() };
const context: ClearStatusContext = {
activeStatusIndicator: { kind: "working", dispose },
activeWorkingIndicatorEmbedded: true,
statusContainer: new Container(),
defaultEditor: editor,
editor,
options: { tuiMode: "regular" },
ui: { getClearOnShrink: () => true },
idleStatus: new Text("", 0, 0),
setEditorWorkingStatusIndicator: interactiveModePrototype.setEditorWorkingStatusIndicator,
};
interactiveModePrototype.clearStatusIndicator.call(context);
expect(dispose).toHaveBeenCalledOnce();
expect(editor.setWorkingStatusIndicator).toHaveBeenCalledWith(undefined);
expect(context.statusContainer.children).toHaveLength(0);
});
it("uses the standalone row for a custom editor that has not opted in", () => {
for (const [tuiMode, expectedChildren] of [
["regular", 1],
["fullscreen", 0],
] as const) {
const dispose = vi.fn();
const defaultEditor: StatusEditor = { embedWorkingStatus: true, setWorkingStatusIndicator: vi.fn() };
const customEditor = { embedWorkingStatus: false, setWorkingStatusIndicator: vi.fn() };
const context: ClearStatusContext = {
activeStatusIndicator: { kind: "working", dispose },
activeStatusIndicator: { kind: "working", dispose: vi.fn() },
activeWorkingIndicatorEmbedded: false,
statusContainer: new Container(),
defaultEditor,
editor: customEditor,
options: { tuiMode },
ui: { getClearOnShrink: () => true },
idleStatus: new Text("", 0, 0),
setEditorWorkingStatusIndicator: interactiveModePrototype.setEditorWorkingStatusIndicator,
};
interactiveModePrototype.clearStatusIndicator.call(context);
expect(dispose).toHaveBeenCalledOnce();
expect(defaultEditor.setWorkingStatusIndicator).toHaveBeenCalledWith(undefined);
expect(customEditor.setWorkingStatusIndicator).not.toHaveBeenCalled();
expect(context.statusContainer.children).toHaveLength(expectedChildren);
}
});

View file

@ -1,14 +1,21 @@
import type { TUI } from "@earendil-works/pi-tui";
import { type TUI, visibleWidth } from "@earendil-works/pi-tui";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IdleStatus, RetryStatusIndicator } from "../src/modes/interactive/components/status-indicator.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import { CustomEditor } from "../src/modes/interactive/components/custom-editor.ts";
import {
IdleStatus,
RetryStatusIndicator,
WorkingStatusIndicator,
} from "../src/modes/interactive/components/status-indicator.ts";
import { getEditorTheme, initTheme, theme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("status indicators", () => {
afterEach(() => {
vi.useRealTimers();
});
it("keeps idle status at the same height as status indicators", () => {
it("keeps idle status at the same height as standalone status indicators", () => {
const idleStatus = new IdleStatus();
const lines = idleStatus.render(20);
@ -16,6 +23,44 @@ describe("status indicators", () => {
expect(lines).toEqual([" ".repeat(20), " ".repeat(20)]);
});
it("keeps the top border unchanged unless the editor opts in", () => {
initTheme("dark");
const tui = {
requestRender: vi.fn(),
terminal: { rows: 10 },
} as unknown as TUI;
const editor = new CustomEditor(tui, getEditorTheme(), KeybindingsManager.create());
const indicator = new WorkingStatusIndicator(tui, "Working");
editor.setWorkingStatusIndicator(indicator);
expect(stripAnsi(editor.render(20)[0]!)).toBe("─".repeat(20));
const standaloneLine = indicator.render(20)[1]!;
expect(standaloneLine).toContain(theme.getFgAnsi("accent"));
expect(standaloneLine).toContain(theme.getFgAnsi("muted"));
indicator.dispose();
});
it("embeds the working indicator when the editor opts in", () => {
initTheme("dark");
const tui = {
requestRender: vi.fn(),
terminal: { rows: 10 },
} as unknown as TUI;
const editor = new CustomEditor(tui, getEditorTheme(), KeybindingsManager.create(), {
embedWorkingStatus: true,
});
expect(editor.embedWorkingStatus).toBe(true);
editor.borderColor = theme.getThinkingBorderColor("high");
const indicator = new WorkingStatusIndicator(tui, "Working", undefined, (text) => editor.borderColor(text));
editor.setWorkingStatusIndicator(indicator);
const topBorder = editor.render(20)[0]!;
expect(stripAnsi(topBorder)).toBe("── ⠋ Working ───────");
expect(visibleWidth(topBorder)).toBe(20);
expect(topBorder.split(theme.getFgAnsi("thinkingHigh"))).toHaveLength(5);
indicator.dispose();
});
it("disposes retry countdown updates", () => {
initTheme("dark");
vi.useFakeTimers();

View file

@ -265,6 +265,13 @@ function buildDebouncePattern(triggerCharacters: string[]): RegExp {
function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string {
const availableWidth = Math.max(0, width);
const label = ` ${direction} ${hiddenLineCount} more `;
const labelWidth = visibleWidth(label);
if (labelWidth + 2 <= availableWidth) {
const leftWidth = Math.floor((availableWidth - labelWidth) / 2);
return "─".repeat(leftWidth) + label + "─".repeat(availableWidth - leftWidth - labelWidth);
}
const indicator = `─── ${direction} ${hiddenLineCount} more `;
const remaining = availableWidth - visibleWidth(indicator);
if (remaining >= 0) return indicator + "─".repeat(remaining);
@ -488,6 +495,16 @@ export class Editor implements Component, Focusable {
// No cached state to invalidate currently
}
protected renderTopBorder(width: number, hiddenLineCount: number): string {
const border = hiddenLineCount > 0 ? createScrollBorder("↑", hiddenLineCount, width) : "─".repeat(width);
return this.borderColor(border);
}
protected renderBottomBorder(width: number, hiddenLineCount: number): string {
const border = hiddenLineCount > 0 ? createScrollBorder("↓", hiddenLineCount, width) : "─".repeat(width);
return this.borderColor(border);
}
render(width: number): string[] {
const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
const paddingX = Math.min(this.paddingX, maxPadding);
@ -500,8 +517,6 @@ export class Editor implements Component, Focusable {
// Store for cursor navigation (must match wrapping width)
this.lastWidth = layoutWidth;
const horizontal = this.borderColor("─");
// Layout the text
const layoutLines = this.layoutText(layoutWidth);
@ -533,12 +548,7 @@ export class Editor implements Component, Focusable {
const rightPadding = leftPadding;
// Render top border (with scroll indicator if scrolled down)
if (this.scrollOffset > 0) {
const border = createScrollBorder("↑", this.scrollOffset, width);
result.push(this.borderColor(border));
} else {
result.push(horizontal.repeat(width));
}
result.push(this.renderTopBorder(width, this.scrollOffset));
// Render each visible layout line
// Emit hardware cursor marker when focused so TUI can position the
@ -590,12 +600,7 @@ export class Editor implements Component, Focusable {
// Render bottom border (with scroll indicator if more content below)
const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
if (linesBelow > 0) {
const border = createScrollBorder("↓", linesBelow, width);
result.push(this.borderColor(border));
} else {
result.push(horizontal.repeat(width));
}
result.push(this.renderBottomBorder(width, linesBelow));
// Add autocomplete list if active
this.renderedAutocompleteHeight = 0;

View file

@ -61,6 +61,11 @@ export class Loader extends Text {
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
setIndicator(indicator?: LoaderIndicatorOptions): void {
this.renderIndicatorVerbatim = indicator !== undefined;
this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES];
@ -80,10 +85,14 @@ export class Loader extends Text {
}, this.intervalMs);
}
private updateDisplay(): void {
protected getRenderedIndicator(): string {
const frame = this.frames[this.currentFrame] ?? "";
const renderedFrame = this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);
const indicator = frame.length > 0 ? `${renderedFrame} ` : "";
return this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);
}
private updateDisplay(): void {
const renderedFrame = this.getRenderedIndicator();
const indicator = renderedFrame.length > 0 ? `${renderedFrame} ` : "";
this.setText(`${indicator}${this.messageColorFn(this.message)}`);
if (this.ui) {
this.ui.requestRender();

View file

@ -701,6 +701,19 @@ describe("Editor component", () => {
});
describe("Scroll indicators", () => {
it("centers scroll indicators on wide borders", () => {
const width = 40;
const editor = new Editor(createTestTUI(width), defaultEditorTheme);
editor.setText(Array.from({ length: 20 }, (_, index) => `line ${index}`).join("\n"));
editor.render(width);
for (let index = 0; index < 10; index++) editor.handleInput("\x1b[A");
const lines = editor.render(width);
assert.strictEqual(stripVTControlCharacters(lines[0]!), `${"─".repeat(15)} ↑ 9 more ${"─".repeat(15)}`);
assert.strictEqual(stripVTControlCharacters(lines.at(-1)!), `${"─".repeat(15)} ↓ 4 more ${"─".repeat(15)}`);
});
it("keeps truncated scroll indicators within width and preserves their color (issue #6962)", () => {
const width = 10;
const borderColor = (text: string) => `\x1b[35m${text}\x1b[39m`;