File size: 5,146 Bytes
bc575bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | import { Editor, type EditorOptions, type EditorTheme, type TUI, visibleWidth } from "@earendil-works/pi-tui";
import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts";
import type { StatusIndicator } from "./status-indicator.ts";
export type CustomEditorOptions = EditorOptions & {
/** Render working, compaction, summarization, and retry 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: StatusIndicator | undefined;
public readonly embedWorkingStatus: boolean;
public actionHandlers: Map<AppKeybinding, () => void> = new Map();
// Special handlers that can be dynamically replaced
public onEscape?: () => void;
public onCtrlD?: () => void;
public onPasteImage?: () => void;
/** Handler for extension-registered shortcuts. Returns true if handled. */
public onExtensionShortcut?: (data: string) => boolean;
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options?: CustomEditorOptions) {
super(tui, theme, options);
this.keybindings = keybindings;
this.embedWorkingStatus = options?.embedWorkingStatus ?? false;
}
setWorkingStatusIndicator(indicator: StatusIndicator | 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)))
);
}
/**
* Register a handler for an app action.
*/
onAction(action: AppKeybinding, handler: () => void): void {
this.actionHandlers.set(action, handler);
}
handleInput(data: string): void {
// Check extension-registered shortcuts first
if (this.onExtensionShortcut?.(data)) {
return;
}
// Check for clipboard paste keybinding
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
this.onPasteImage?.();
return;
}
// Check app keybindings first
// Escape/interrupt - only if autocomplete is NOT active
if (this.keybindings.matches(data, "app.interrupt")) {
if (!this.isShowingAutocomplete()) {
// Use dynamic onEscape if set, otherwise registered handler
const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt");
if (handler) {
handler();
return;
}
}
// Let parent handle escape for autocomplete cancellation
super.handleInput(data);
return;
}
// Exit (Ctrl+D) - only when editor is empty
if (this.keybindings.matches(data, "app.exit")) {
if (this.getText().length === 0) {
const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit");
if (handler) handler();
return;
}
// Fall through to editor handling for delete-char-forward when not empty
}
// Explicit history bindings take precedence over app actions while the editor is focused.
// This lets users bind Ctrl+P even though it cycles models by default.
if (
this.keybindings.matches(data, "tui.editor.historyPrevious") ||
this.keybindings.matches(data, "tui.editor.historyNext")
) {
super.handleInput(data);
return;
}
// Check all other app actions
for (const [action, handler] of this.actionHandlers) {
if (action !== "app.interrupt" && action !== "app.exit" && this.keybindings.matches(data, action)) {
handler();
return;
}
}
// Pass to parent for editor handling
super.handleInput(data);
}
}
|