mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): prettier Working... spinner (#8799)
This commit is contained in:
parent
e44d75c20a
commit
1d9787c11f
14 changed files with 275 additions and 39 deletions
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ export {
|
|||
BranchSummaryMessageComponent,
|
||||
CompactionSummaryMessageComponent,
|
||||
CustomEditor,
|
||||
type CustomEditorOptions,
|
||||
CustomMessageComponent,
|
||||
DynamicBorder,
|
||||
ExtensionEditorComponent,
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue