From 1d9787c11fb91ecf7c892050f4c0607a995dd15b Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:34:04 +0200 Subject: [PATCH] feat(tui): prettier Working... spinner (#8799) --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/extensions.md | 1 + packages/coding-agent/docs/tui.md | 1 + packages/coding-agent/src/index.ts | 1 + .../interactive/components/custom-editor.ts | 62 ++++++++++++++++++- .../src/modes/interactive/components/index.ts | 2 +- .../components/status-indicator.ts | 17 +++-- .../src/modes/interactive/interactive-mode.ts | 60 ++++++++++++++++-- .../coding-agent/test/interactive-tui.test.ts | 50 +++++++++++++-- .../test/status-indicator.test.ts | 53 ++++++++++++++-- packages/tui/src/components/editor.ts | 33 +++++----- packages/tui/src/components/loader.ts | 15 ++++- packages/tui/test/editor.test.ts | 13 ++++ 14 files changed, 275 insertions(+), 39 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 59ab2d754..72e29a3a5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 63e8cac7a..27ca4c9e2 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index dd89ceba1..ce13db622 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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)` diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index ca362a9d4..81b2387a5 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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)` diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8e5ce2f3d..6667389f1 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -372,6 +372,7 @@ export { BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomEditor, + type CustomEditorOptions, CustomMessageComponent, DynamicBorder, ExtensionEditorComponent, diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index c04f99b03..057dc8349 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -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 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))) + ); } /** diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 38c2b9879..326adcc53 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts index cb7f2ef82..4efeb41ee 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -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 { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2674bdfc3..d8e387e21 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); } diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index cd94dfb61..337598d0c 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -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; 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); } }); diff --git a/packages/coding-agent/test/status-indicator.test.ts b/packages/coding-agent/test/status-indicator.test.ts index 72267fb3f..412dff08e 100644 --- a/packages/coding-agent/test/status-indicator.test.ts +++ b/packages/coding-agent/test/status-indicator.test.ts @@ -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(); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ddba3f6eb..42b722f8c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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; diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 4a93eb08f..0c59e73a8 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -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(); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 0862e6cc8..73fddc822 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -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`;