diff --git a/crates/fresh-editor/plugins/lib/controls.ts b/crates/fresh-editor/plugins/lib/controls.ts
new file mode 100644
index 000000000..36cf39e0d
--- /dev/null
+++ b/crates/fresh-editor/plugins/lib/controls.ts
@@ -0,0 +1,1135 @@
+///
+
+/**
+ * UI Controls Library for Fresh Editor Plugins
+ *
+ * Provides TypeScript controls that mirror the Rust control patterns used in
+ * the editor's Settings UI. This eliminates manual text construction and
+ * byte offset calculation when building plugin UIs.
+ *
+ * @example
+ * ```typescript
+ * import { ButtonControl, ListControl, FocusManager, FocusState } from "./lib/controls.ts";
+ *
+ * const button = new ButtonControl("Install", FocusState.Focused);
+ * const { text, styles } = button.render();
+ * ```
+ */
+
+import type { RGB } from "./types.ts";
+
+// =============================================================================
+// Focus State
+// =============================================================================
+
+/**
+ * Focus state for controls - mirrors FocusState in Rust controls
+ */
+export enum FocusState {
+ Normal = "normal",
+ Focused = "focused",
+ Hovered = "hovered",
+ Disabled = "disabled",
+}
+
+// =============================================================================
+// Style Types
+// =============================================================================
+
+/**
+ * Style range for text coloring
+ *
+ * Uses character offsets (not bytes) - the VirtualBufferBuilder handles
+ * UTF-8 conversion automatically.
+ */
+export interface StyleRange {
+ /** Start character offset (0-indexed) */
+ start: number;
+ /** End character offset (exclusive) */
+ end: number;
+ /** Foreground color - theme key (e.g., "syntax.keyword") or RGB tuple */
+ fg?: string | RGB;
+ /** Background color - theme key or RGB tuple */
+ bg?: string | RGB;
+ /** Bold text */
+ bold?: boolean;
+ /** Underline text */
+ underline?: boolean;
+}
+
+/**
+ * Rendered output from a control
+ */
+export interface ControlOutput {
+ /** The rendered text content */
+ text: string;
+ /** Style ranges to apply */
+ styles: StyleRange[];
+}
+
+// =============================================================================
+// Button Control
+// =============================================================================
+
+/**
+ * Button control - mirrors controls/button in Rust
+ *
+ * Renders a button with focus indicators (brackets when focused).
+ *
+ * @example
+ * ```typescript
+ * const button = new ButtonControl("Save", FocusState.Focused);
+ * const { text, styles } = button.render();
+ * // text: "[ Save ]"
+ * ```
+ */
+export class ButtonControl {
+ constructor(
+ /** Button label text */
+ public label: string,
+ /** Current focus state */
+ public focus: FocusState = FocusState.Normal,
+ /** Theme color for focused state */
+ public focusedBg: string | RGB = "ui.menu_active_bg",
+ /** Theme color for focused foreground */
+ public focusedFg: string | RGB = "ui.menu_active_fg",
+ /** Theme color for normal state */
+ public normalFg: string | RGB = "ui.fg",
+ ) {}
+
+ /**
+ * Render the button text with focus indicators
+ */
+ render(): ControlOutput {
+ const focused = this.focus === FocusState.Focused;
+ const hovered = this.focus === FocusState.Hovered;
+ const disabled = this.focus === FocusState.Disabled;
+
+ // Show brackets when focused, spaces otherwise (to maintain alignment)
+ const left = focused ? "[" : " ";
+ const right = focused ? "]" : " ";
+ const text = `${left} ${this.label} ${right}`;
+
+ const styles: StyleRange[] = [];
+
+ if (disabled) {
+ styles.push({
+ start: 0,
+ end: text.length,
+ fg: "ui.fg_muted",
+ });
+ } else if (focused) {
+ styles.push({
+ start: 0,
+ end: text.length,
+ fg: this.focusedFg,
+ bg: this.focusedBg,
+ });
+ } else if (hovered) {
+ styles.push({
+ start: 0,
+ end: text.length,
+ fg: this.focusedFg,
+ bg: this.focusedBg,
+ });
+ }
+
+ return { text, styles };
+ }
+
+ /**
+ * Get the rendered width of this button
+ */
+ get width(): number {
+ return this.label.length + 4; // "[ " + label + " ]"
+ }
+}
+
+// =============================================================================
+// Toggle Button Control
+// =============================================================================
+
+/**
+ * Toggle button - a button that shows on/off state
+ *
+ * @example
+ * ```typescript
+ * const toggle = new ToggleButton("Dark Mode", true, FocusState.Normal);
+ * const { text } = toggle.render();
+ * // text: " Dark Mode [ON] "
+ * ```
+ */
+export class ToggleButton {
+ constructor(
+ public label: string,
+ public isOn: boolean = false,
+ public focus: FocusState = FocusState.Normal,
+ ) {}
+
+ render(): ControlOutput {
+ const focused = this.focus === FocusState.Focused;
+ const indicator = this.isOn ? "[ON]" : "[OFF]";
+ const left = focused ? "[" : " ";
+ const right = focused ? "]" : " ";
+ const text = `${left} ${this.label} ${indicator} ${right}`;
+
+ const styles: StyleRange[] = [];
+ if (focused) {
+ styles.push({
+ start: 0,
+ end: text.length,
+ fg: "ui.menu_active_fg",
+ bg: "ui.menu_active_bg",
+ });
+ }
+
+ return { text, styles };
+ }
+}
+
+// =============================================================================
+// List Control
+// =============================================================================
+
+/**
+ * List item renderer function type
+ */
+export type ItemRenderer = (
+ item: T,
+ selected: boolean,
+ index: number,
+) => string;
+
+/**
+ * Selectable list control - mirrors Settings item list behavior
+ *
+ * Handles selection, scrolling, and rendering with selection indicators.
+ *
+ * @example
+ * ```typescript
+ * interface Package { name: string; version: string; }
+ *
+ * const list = new ListControl(
+ * packages,
+ * (pkg, selected) => `${pkg.name} v${pkg.version}`,
+ * { maxVisible: 10, selectionPrefix: ">" }
+ * );
+ *
+ * list.selectNext();
+ * const { text, styles, selectedLine } = list.render();
+ * ```
+ */
+export class ListControl {
+ /** Currently selected index */
+ public selectedIndex: number = 0;
+ /** Current scroll offset */
+ public scrollOffset: number = 0;
+
+ private _maxVisible: number;
+ private _selectionPrefix: string;
+ private _emptyPrefix: string;
+ private _selectedFg: string | RGB;
+ private _selectedBg: string | RGB;
+
+ constructor(
+ /** Items to display */
+ public items: T[],
+ /** Function to render each item to a string */
+ public renderItem: ItemRenderer,
+ options: {
+ /** Maximum visible items before scrolling (default: 10) */
+ maxVisible?: number;
+ /** Prefix for selected item (default: "▸ ") */
+ selectionPrefix?: string;
+ /** Prefix for non-selected items (default: " ") */
+ emptyPrefix?: string;
+ /** Selected item foreground color */
+ selectedFg?: string | RGB;
+ /** Selected item background color */
+ selectedBg?: string | RGB;
+ } = {},
+ ) {
+ this._maxVisible = options.maxVisible ?? 10;
+ this._selectionPrefix = options.selectionPrefix ?? "▸ ";
+ this._emptyPrefix = options.emptyPrefix ?? " ";
+ this._selectedFg = options.selectedFg ?? "ui.menu_active_fg";
+ this._selectedBg = options.selectedBg ?? "ui.menu_active_bg";
+ }
+
+ /**
+ * Select the next item
+ */
+ selectNext(): void {
+ if (this.items.length === 0) return;
+ this.selectedIndex = Math.min(
+ this.selectedIndex + 1,
+ this.items.length - 1,
+ );
+ this.ensureVisible();
+ }
+
+ /**
+ * Select the previous item
+ */
+ selectPrev(): void {
+ if (this.items.length === 0) return;
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
+ this.ensureVisible();
+ }
+
+ /**
+ * Select first item
+ */
+ selectFirst(): void {
+ this.selectedIndex = 0;
+ this.ensureVisible();
+ }
+
+ /**
+ * Select last item
+ */
+ selectLast(): void {
+ if (this.items.length === 0) return;
+ this.selectedIndex = this.items.length - 1;
+ this.ensureVisible();
+ }
+
+ /**
+ * Get the currently selected item
+ */
+ selectedItem(): T | undefined {
+ return this.items[this.selectedIndex];
+ }
+
+ /**
+ * Update items and reset selection if needed
+ */
+ setItems(items: T[]): void {
+ this.items = items;
+ if (this.selectedIndex >= items.length) {
+ this.selectedIndex = Math.max(0, items.length - 1);
+ }
+ this.ensureVisible();
+ }
+
+ /**
+ * Ensure the selected item is visible by adjusting scroll offset
+ */
+ private ensureVisible(): void {
+ if (this.selectedIndex < this.scrollOffset) {
+ this.scrollOffset = this.selectedIndex;
+ } else if (this.selectedIndex >= this.scrollOffset + this._maxVisible) {
+ this.scrollOffset = this.selectedIndex - this._maxVisible + 1;
+ }
+ }
+
+ /**
+ * Render the list
+ */
+ render(): ControlOutput & { selectedLine: number } {
+ const lines: string[] = [];
+ const styles: StyleRange[] = [];
+ let charOffset = 0;
+
+ const visibleItems = this.items.slice(
+ this.scrollOffset,
+ this.scrollOffset + this._maxVisible,
+ );
+
+ for (let i = 0; i < visibleItems.length; i++) {
+ const actualIndex = this.scrollOffset + i;
+ const selected = actualIndex === this.selectedIndex;
+ const prefix = selected ? this._selectionPrefix : this._emptyPrefix;
+ const line = prefix +
+ this.renderItem(visibleItems[i], selected, actualIndex);
+ lines.push(line);
+
+ if (selected) {
+ styles.push({
+ start: charOffset,
+ end: charOffset + line.length,
+ fg: this._selectedFg,
+ bg: this._selectedBg,
+ });
+ }
+ charOffset += line.length + 1; // +1 for \n
+ }
+
+ return {
+ text: lines.join("\n"),
+ styles,
+ selectedLine: this.selectedIndex - this.scrollOffset,
+ };
+ }
+
+ /**
+ * Check if there are more items above the visible area
+ */
+ get hasScrollUp(): boolean {
+ return this.scrollOffset > 0;
+ }
+
+ /**
+ * Check if there are more items below the visible area
+ */
+ get hasScrollDown(): boolean {
+ return this.scrollOffset + this._maxVisible < this.items.length;
+ }
+
+ /**
+ * Get the number of items
+ */
+ get length(): number {
+ return this.items.length;
+ }
+
+ /**
+ * Check if the list is empty
+ */
+ get isEmpty(): boolean {
+ return this.items.length === 0;
+ }
+}
+
+// =============================================================================
+// Grouped List Control
+// =============================================================================
+
+/**
+ * A group of items with a title
+ */
+export interface ListGroup {
+ /** Group title (e.g., "INSTALLED", "AVAILABLE") */
+ title: string;
+ /** Items in this group */
+ items: T[];
+}
+
+/**
+ * List control with grouped sections
+ *
+ * Useful for showing categorized lists like "Installed" and "Available" packages.
+ *
+ * @example
+ * ```typescript
+ * const groupedList = new GroupedListControl(
+ * [
+ * { title: "INSTALLED (3)", items: installedPackages },
+ * { title: "AVAILABLE (10)", items: availablePackages },
+ * ],
+ * (pkg, selected) => `${pkg.name} v${pkg.version}`
+ * );
+ * ```
+ */
+export class GroupedListControl {
+ public selectedIndex: number = 0;
+ public scrollOffset: number = 0;
+
+ private _maxVisible: number;
+ private _selectionPrefix: string;
+ private _emptyPrefix: string;
+ private _titleFg: string | RGB;
+ private _selectedFg: string | RGB;
+ private _selectedBg: string | RGB;
+ private _itemFg?: string | RGB;
+
+ constructor(
+ public groups: ListGroup[],
+ public renderItem: ItemRenderer,
+ options: {
+ maxVisible?: number;
+ selectionPrefix?: string;
+ emptyPrefix?: string;
+ /** Title foreground color */
+ titleFg?: string | RGB;
+ /** Selected item foreground */
+ selectedFg?: string | RGB;
+ /** Selected item background */
+ selectedBg?: string | RGB;
+ /** Normal item foreground (optional) */
+ itemFg?: string | RGB;
+ } = {},
+ ) {
+ this._maxVisible = options.maxVisible ?? 10;
+ this._selectionPrefix = options.selectionPrefix ?? "▸ ";
+ this._emptyPrefix = options.emptyPrefix ?? " ";
+ this._titleFg = options.titleFg ?? "syntax.keyword";
+ this._selectedFg = options.selectedFg ?? "ui.menu_active_fg";
+ this._selectedBg = options.selectedBg ?? "ui.menu_active_bg";
+ this._itemFg = options.itemFg;
+ }
+
+ /**
+ * Get all items flattened
+ */
+ private get allItems(): T[] {
+ return this.groups.flatMap((g) => g.items);
+ }
+
+ /**
+ * Get total item count
+ */
+ get length(): number {
+ return this.allItems.length;
+ }
+
+ selectNext(): void {
+ const total = this.length;
+ if (total === 0) return;
+ this.selectedIndex = Math.min(this.selectedIndex + 1, total - 1);
+ }
+
+ selectPrev(): void {
+ if (this.length === 0) return;
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
+ }
+
+ selectedItem(): T | undefined {
+ return this.allItems[this.selectedIndex];
+ }
+
+ render(): ControlOutput {
+ const lines: string[] = [];
+ const styles: StyleRange[] = [];
+ let charOffset = 0;
+ let itemIndex = 0;
+
+ for (const group of this.groups) {
+ // Group title
+ if (lines.length > 0) {
+ lines.push(""); // Blank line between groups
+ charOffset += 1;
+ }
+
+ const titleLine = group.title;
+ lines.push(titleLine);
+ styles.push({
+ start: charOffset,
+ end: charOffset + titleLine.length,
+ fg: this._titleFg,
+ bold: true,
+ });
+ charOffset += titleLine.length + 1;
+
+ // Group items
+ for (const item of group.items) {
+ const selected = itemIndex === this.selectedIndex;
+ const prefix = selected ? this._selectionPrefix : this._emptyPrefix;
+ const line = prefix + this.renderItem(item, selected, itemIndex);
+ lines.push(line);
+
+ if (selected) {
+ styles.push({
+ start: charOffset,
+ end: charOffset + line.length,
+ fg: this._selectedFg,
+ bg: this._selectedBg,
+ });
+ } else if (this._itemFg) {
+ styles.push({
+ start: charOffset,
+ end: charOffset + line.length,
+ fg: this._itemFg,
+ });
+ }
+
+ charOffset += line.length + 1;
+ itemIndex++;
+ }
+ }
+
+ return {
+ text: lines.join("\n"),
+ styles,
+ };
+ }
+
+ /**
+ * Render to individual lines - useful for split-view layouts
+ * Returns array of line objects with text and optional styling
+ */
+ renderLines(): Array<
+ { text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean }
+ > {
+ const result: Array<
+ { text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean }
+ > = [];
+ let itemIndex = 0;
+
+ for (const group of this.groups) {
+ // Blank line between groups
+ if (result.length > 0) {
+ result.push({ text: "" });
+ }
+
+ // Group title
+ result.push({
+ text: group.title,
+ fg: this._titleFg,
+ isTitle: true,
+ });
+
+ // Group items
+ for (const item of group.items) {
+ const selected = itemIndex === this.selectedIndex;
+ const prefix = selected ? this._selectionPrefix : this._emptyPrefix;
+ const line = prefix + this.renderItem(item, selected, itemIndex);
+
+ if (selected) {
+ result.push({
+ text: line,
+ fg: this._selectedFg,
+ bg: this._selectedBg,
+ });
+ } else {
+ result.push({ text: line, fg: this._itemFg });
+ }
+ itemIndex++;
+ }
+ }
+
+ return result;
+ }
+}
+
+// =============================================================================
+// Focus Manager
+// =============================================================================
+
+/**
+ * Manages focus cycling through a list of elements
+ *
+ * This mirrors FocusManager from Rust (src/view/ui/focus.rs).
+ * Use it to handle Tab-order navigation between UI regions.
+ *
+ * @example
+ * ```typescript
+ * type Panel = "search" | "filters" | "list" | "details";
+ * const focus = new FocusManager(["search", "filters", "list", "details"]);
+ *
+ * focus.current(); // "search"
+ * focus.focusNext(); // "filters"
+ * focus.focusNext(); // "list"
+ * focus.isFocused("list"); // true
+ * ```
+ */
+export class FocusManager {
+ private currentIndex: number = 0;
+
+ constructor(
+ /** Ordered list of focusable elements */
+ public elements: T[],
+ ) {}
+
+ /**
+ * Get the currently focused element
+ */
+ current(): T | undefined {
+ return this.elements[this.currentIndex];
+ }
+
+ /**
+ * Get the current index
+ */
+ index(): number {
+ return this.currentIndex;
+ }
+
+ /**
+ * Move focus to the next element (wraps around)
+ */
+ focusNext(): T | undefined {
+ if (this.elements.length === 0) return undefined;
+ this.currentIndex = (this.currentIndex + 1) % this.elements.length;
+ return this.current();
+ }
+
+ /**
+ * Move focus to the previous element (wraps around)
+ */
+ focusPrev(): T | undefined {
+ if (this.elements.length === 0) return undefined;
+ this.currentIndex = (this.currentIndex + this.elements.length - 1) %
+ this.elements.length;
+ return this.current();
+ }
+
+ /**
+ * Check if an element is currently focused
+ */
+ isFocused(element: T): boolean {
+ return this.elements[this.currentIndex] === element;
+ }
+
+ /**
+ * Set focus to a specific element
+ * @returns true if element was found and focused
+ */
+ focus(element: T): boolean {
+ const idx = this.elements.indexOf(element);
+ if (idx >= 0) {
+ this.currentIndex = idx;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Set focus by index
+ * @returns true if index was valid
+ */
+ focusByIndex(index: number): boolean {
+ if (index >= 0 && index < this.elements.length) {
+ this.currentIndex = index;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Get the number of elements
+ */
+ get length(): number {
+ return this.elements.length;
+ }
+
+ /**
+ * Check if empty
+ */
+ get isEmpty(): boolean {
+ return this.elements.length === 0;
+ }
+}
+
+// =============================================================================
+// Text Input Control
+// =============================================================================
+
+/**
+ * Text input control for search boxes and text entry
+ *
+ * @example
+ * ```typescript
+ * const search = new TextInputControl("Search:", 30);
+ * search.value = "query";
+ * const { text, styles } = search.render(FocusState.Focused);
+ * // text: "Search: [query ]"
+ * ```
+ */
+export class TextInputControl {
+ /** Current input value */
+ public value: string = "";
+ /** Cursor position */
+ public cursor: number = 0;
+
+ constructor(
+ /** Label shown before the input */
+ public label: string,
+ /** Width of the input field */
+ public width: number = 20,
+ ) {}
+
+ /**
+ * Insert text at cursor position
+ */
+ insert(text: string): void {
+ this.value = this.value.slice(0, this.cursor) + text +
+ this.value.slice(this.cursor);
+ this.cursor += text.length;
+ }
+
+ /**
+ * Delete character before cursor
+ */
+ backspace(): void {
+ if (this.cursor > 0) {
+ this.value = this.value.slice(0, this.cursor - 1) +
+ this.value.slice(this.cursor);
+ this.cursor--;
+ }
+ }
+
+ /**
+ * Delete character at cursor
+ */
+ delete(): void {
+ if (this.cursor < this.value.length) {
+ this.value = this.value.slice(0, this.cursor) +
+ this.value.slice(this.cursor + 1);
+ }
+ }
+
+ /**
+ * Clear the input
+ */
+ clear(): void {
+ this.value = "";
+ this.cursor = 0;
+ }
+
+ /**
+ * Render the input field
+ */
+ render(focus: FocusState = FocusState.Normal): ControlOutput {
+ const focused = focus === FocusState.Focused;
+
+ // Truncate or pad the display value
+ let display = this.value;
+ if (display.length > this.width - 1) {
+ display = display.slice(0, this.width - 2) + "...";
+ } else {
+ display = display.padEnd(this.width);
+ }
+
+ const left = focused ? "[" : " ";
+ const right = focused ? "]" : " ";
+ const text = `${this.label}${left}${display}${right}`;
+
+ const styles: StyleRange[] = [];
+ const inputStart = this.label.length;
+ const inputEnd = text.length;
+
+ if (focused) {
+ styles.push({
+ start: inputStart,
+ end: inputEnd,
+ fg: "ui.menu_active_fg",
+ bg: "ui.menu_active_bg",
+ });
+ } else {
+ styles.push({
+ start: inputStart,
+ end: inputEnd,
+ fg: "ui.fg",
+ bg: "ui.bg_subtle",
+ });
+ }
+
+ return { text, styles };
+ }
+}
+
+// =============================================================================
+// Separator
+// =============================================================================
+
+/**
+ * Horizontal separator line
+ */
+export class Separator {
+ constructor(
+ public width: number,
+ public char: string = "─",
+ ) {}
+
+ render(): ControlOutput {
+ const text = this.char.repeat(this.width);
+ return {
+ text,
+ styles: [{
+ start: 0,
+ end: text.length,
+ fg: "ui.border",
+ }],
+ };
+ }
+}
+
+// =============================================================================
+// Label
+// =============================================================================
+
+/**
+ * Simple text label with optional styling
+ */
+export class Label {
+ constructor(
+ public text: string,
+ public fg?: string | RGB,
+ public bg?: string | RGB,
+ public bold?: boolean,
+ ) {}
+
+ render(): ControlOutput {
+ const styles: StyleRange[] = [];
+ if (this.fg || this.bg || this.bold) {
+ styles.push({
+ start: 0,
+ end: this.text.length,
+ fg: this.fg,
+ bg: this.bg,
+ bold: this.bold,
+ });
+ }
+ return { text: this.text, styles };
+ }
+}
+
+// =============================================================================
+// Panel Line (for split views)
+// =============================================================================
+
+/**
+ * A line in a panel with optional styling
+ */
+export interface PanelLine {
+ text: string;
+ fg?: string | RGB;
+ bg?: string | RGB;
+}
+
+// =============================================================================
+// Split View
+// =============================================================================
+
+/**
+ * Renders two panels side-by-side with a divider
+ *
+ * @example
+ * ```typescript
+ * const split = new SplitView(leftLines, rightLines, {
+ * leftWidth: 40,
+ * divider: "│",
+ * dividerFg: "ui.border",
+ * minRows: 8,
+ * });
+ * const { text, styles } = split.render();
+ * ```
+ */
+export class SplitView {
+ constructor(
+ public leftLines: PanelLine[],
+ public rightLines: PanelLine[],
+ public options: {
+ /** Width of left panel (right panel takes remaining space) */
+ leftWidth: number;
+ /** Divider character (default: "│") */
+ divider?: string;
+ /** Divider foreground color */
+ dividerFg?: string | RGB;
+ /** Minimum number of rows to render */
+ minRows?: number;
+ /** Left padding for left panel */
+ leftPadding?: string;
+ /** Left padding for right panel */
+ rightPadding?: string;
+ },
+ ) {}
+
+ render(): ControlOutput {
+ const {
+ leftWidth,
+ divider = "│",
+ dividerFg = "ui.border",
+ minRows = 0,
+ leftPadding = " ",
+ rightPadding = " ",
+ } = this.options;
+
+ const lines: string[] = [];
+ const styles: StyleRange[] = [];
+ let charOffset = 0;
+
+ const maxRows = Math.max(
+ this.leftLines.length,
+ this.rightLines.length,
+ minRows,
+ );
+
+ for (let i = 0; i < maxRows; i++) {
+ const leftItem = this.leftLines[i];
+ const rightItem = this.rightLines[i];
+
+ // Left side (padded to fixed width)
+ const leftContent = leftItem ? (leftPadding + leftItem.text) : "";
+ const leftText = leftContent.padEnd(leftWidth);
+
+ if (leftItem?.fg || leftItem?.bg) {
+ styles.push({
+ start: charOffset,
+ end: charOffset + leftText.length,
+ fg: leftItem.fg,
+ bg: leftItem.bg,
+ });
+ }
+ charOffset += leftText.length;
+
+ // Divider
+ styles.push({
+ start: charOffset,
+ end: charOffset + divider.length,
+ fg: dividerFg,
+ });
+ charOffset += divider.length;
+
+ // Right side
+ const rightText = rightItem ? (rightPadding + rightItem.text) : "";
+
+ if (rightItem?.fg || rightItem?.bg) {
+ styles.push({
+ start: charOffset,
+ end: charOffset + rightText.length,
+ fg: rightItem.fg,
+ bg: rightItem.bg,
+ });
+ }
+ charOffset += rightText.length;
+
+ lines.push(leftText + divider + rightText);
+ charOffset += 1; // newline
+ }
+
+ return {
+ text: lines.join("\n"),
+ styles,
+ };
+ }
+}
+
+// =============================================================================
+// Filter Bar
+// =============================================================================
+
+/**
+ * Filter option for FilterBar
+ */
+export interface FilterOption {
+ id: string;
+ label: string;
+}
+
+/**
+ * Renders a row of toggle filter buttons
+ *
+ * @example
+ * ```typescript
+ * const filterBar = new FilterBar(
+ * [{ id: "all", label: "All" }, { id: "active", label: "Active" }],
+ * "all", // active filter
+ * 1, // focused index (or -1 for none)
+ * );
+ * const { text, styles } = filterBar.render();
+ * ```
+ */
+export class FilterBar {
+ constructor(
+ public filters: FilterOption[],
+ public activeId: string,
+ public focusedIndex: number = -1,
+ public options: {
+ activeFg?: string | RGB;
+ activeBg?: string | RGB;
+ inactiveFg?: string | RGB;
+ focusedFg?: string | RGB;
+ focusedBg?: string | RGB;
+ } = {},
+ ) {}
+
+ render(): ControlOutput {
+ const {
+ activeFg = [255, 255, 255],
+ activeBg = "syntax.keyword",
+ inactiveFg = [160, 160, 170],
+ focusedFg = [255, 255, 255],
+ focusedBg = [80, 80, 90],
+ } = this.options;
+
+ let text = "";
+ const styles: StyleRange[] = [];
+
+ for (let i = 0; i < this.filters.length; i++) {
+ const f = this.filters[i];
+ const isActive = f.id === this.activeId;
+ const isFocused = i === this.focusedIndex;
+
+ const btn = new ButtonControl(
+ f.label,
+ isFocused ? FocusState.Focused : FocusState.Normal,
+ );
+ const btnText = btn.render().text;
+
+ const start = text.length;
+
+ if (isFocused) {
+ styles.push({
+ start,
+ end: start + btnText.length,
+ fg: isActive ? activeFg : focusedFg,
+ bg: isActive ? activeBg : focusedBg,
+ });
+ } else if (isActive) {
+ styles.push({
+ start,
+ end: start + btnText.length,
+ fg: activeFg,
+ bg: activeBg,
+ });
+ } else {
+ styles.push({
+ start,
+ end: start + btnText.length,
+ fg: inactiveFg,
+ });
+ }
+
+ text += btnText;
+ }
+
+ return { text, styles };
+ }
+}
+
+// =============================================================================
+// Help Bar
+// =============================================================================
+
+/**
+ * Key binding for HelpBar
+ */
+export interface KeyBinding {
+ key: string;
+ action: string;
+}
+
+/**
+ * Renders a help bar with key bindings
+ *
+ * @example
+ * ```typescript
+ * const help = new HelpBar([
+ * { key: "↑↓", action: "Navigate" },
+ * { key: "Tab", action: "Next" },
+ * { key: "Enter", action: "Select" },
+ * { key: "Esc", action: "Close" },
+ * ]);
+ * const { text, styles } = help.render();
+ * // text: " ↑↓ Navigate Tab Next Enter Select Esc Close"
+ * ```
+ */
+export class HelpBar {
+ constructor(
+ public bindings: KeyBinding[],
+ public options: {
+ fg?: string | RGB;
+ separator?: string;
+ prefix?: string;
+ } = {},
+ ) {}
+
+ render(): ControlOutput {
+ const { fg = "syntax.comment", separator = " ", prefix = " " } =
+ this.options;
+
+ const text = prefix +
+ this.bindings.map((b) => `${b.key} ${b.action}`).join(separator);
+
+ return {
+ text,
+ styles: [{
+ start: 0,
+ end: text.length,
+ fg,
+ }],
+ };
+ }
+}
diff --git a/crates/fresh-editor/plugins/lib/finder.ts b/crates/fresh-editor/plugins/lib/finder.ts
index 76d3273d0..3ce240007 100644
--- a/crates/fresh-editor/plugins/lib/finder.ts
+++ b/crates/fresh-editor/plugins/lib/finder.ts
@@ -239,7 +239,7 @@ export function defaultFuzzyFilter(
items: T[],
query: string,
format: (item: T, index: number) => DisplayEntry,
- maxResults: number = 100
+ maxResults: number = 100,
): T[] {
if (query === "" || query.trim() === "") {
return items.slice(0, maxResults);
@@ -296,7 +296,7 @@ export function parseGrepLine(line: string): {
*/
export function parseGrepOutput(
stdout: string,
- maxResults: number = 100
+ maxResults: number = 100,
): Array<{ file: string; line: number; column: number; content: string }> {
const results: Array<{
file: string;
@@ -462,10 +462,12 @@ export class Finder {
this.editor.startPromptWithInitial(
options.title,
this.config.id,
- options.initialQuery
+ options.initialQuery,
);
} else {
- this.editor.debug(`[Finder] calling startPrompt with title="${options.title}", id="${this.config.id}"`);
+ this.editor.debug(
+ `[Finder] calling startPrompt with title="${options.title}", id="${this.config.id}"`,
+ );
const result = this.editor.startPrompt(options.title, this.config.id);
this.editor.debug(`[Finder] startPrompt returned: ${result}`);
}
@@ -598,7 +600,10 @@ export class Finder {
// Register event handlers
this.editor.on("prompt_changed", `${this.handlerPrefix}_changed`);
- this.editor.on("prompt_selection_changed", `${this.handlerPrefix}_selection`);
+ this.editor.on(
+ "prompt_selection_changed",
+ `${this.handlerPrefix}_selection`,
+ );
this.editor.on("prompt_confirmed", `${this.handlerPrefix}_confirmed`);
this.editor.on("prompt_cancelled", `${this.handlerPrefix}_cancelled`);
}
@@ -624,7 +629,7 @@ export class Finder {
this.allItems,
query,
this.config.format,
- this.config.maxResults
+ this.config.maxResults,
);
}
@@ -649,7 +654,7 @@ export class Finder {
private async runSearch(
query: string,
- source: SearchSource
+ source: SearchSource,
): Promise {
const debounceMs = source.debounceMs ?? 150;
const minQueryLength = source.minQueryLength ?? 2;
@@ -711,7 +716,7 @@ export class Finder {
// Parse as grep output by default
const parsed = parseGrepOutput(
result.stdout,
- this.config.maxResults
+ this.config.maxResults,
) as unknown as T[];
this.updatePromptResults(parsed);
@@ -772,7 +777,7 @@ export class Finder {
description: entry.description,
value: `${i}`,
disabled: false,
- })
+ }),
);
this.editor.setPromptSuggestions(suggestions);
@@ -810,10 +815,10 @@ export class Finder {
this.editor.openFile(
entry.location.file,
entry.location.line,
- entry.location.column
+ entry.location.column,
);
this.editor.setStatus(
- `Opened ${entry.location.file}:${entry.location.line}`
+ `Opened ${entry.location.file}:${entry.location.line}`,
);
}
} else {
@@ -883,13 +888,18 @@ export class Finder {
const contextLines = this.getContextLines();
const startLine = Math.max(0, entry.location.line - 1 - contextLines);
- const endLine = Math.min(lines.length, entry.location.line + contextLines);
+ const endLine = Math.min(
+ lines.length,
+ entry.location.line + contextLines,
+ );
const entries: TextPropertyEntry[] = [];
// Header
entries.push({
- text: ` ${entry.location.file}:${entry.location.line}:${entry.location.column ?? 1}\n`,
+ text: ` ${entry.location.file}:${entry.location.line}:${
+ entry.location.column ?? 1
+ }\n`,
properties: { type: "header" },
});
entries.push({
@@ -920,7 +930,7 @@ export class Finder {
this.previewModeName,
"special",
[["q", "close_buffer"]],
- true
+ true,
);
// Create preview split
@@ -945,7 +955,10 @@ export class Finder {
}
} else {
// Update existing preview
- this.editor.setVirtualBufferContent(this.previewState.bufferId, entries);
+ this.editor.setVirtualBufferContent(
+ this.previewState.bufferId,
+ entries,
+ );
}
} catch (e) {
this.editor.debug(`[Finder] Failed to update preview: ${e}`);
@@ -978,7 +991,7 @@ export class Finder {
["Return", `${this.handlerPrefix}_panel_select`],
["Escape", `${this.handlerPrefix}_panel_close`],
],
- true
+ true,
);
// Select handler
@@ -1014,7 +1027,7 @@ export class Finder {
const itemIndex = self.panelState.lineToItemIndex.get(data.line);
if (itemIndex !== undefined && itemIndex < self.panelState.items.length) {
self.editor.setStatus(
- `Item ${itemIndex + 1}/${self.panelState.items.length}`
+ `Item ${itemIndex + 1}/${self.panelState.items.length}`,
);
}
};
@@ -1064,7 +1077,9 @@ export class Finder {
try {
const result = await this.editor.createVirtualBufferInSplit({
- name: `*${this.config.id.charAt(0).toUpperCase() + this.config.id.slice(1)}*`,
+ name: `*${
+ this.config.id.charAt(0).toUpperCase() + this.config.id.slice(1)
+ }*`,
mode: this.modeName,
readOnly: true,
entries,
@@ -1082,7 +1097,9 @@ export class Finder {
this.applyPanelHighlighting();
const count = this.panelState.items.length;
- this.editor.setStatus(`${title}: ${count} item${count !== 1 ? "s" : ""}`);
+ this.editor.setStatus(
+ `${title}: ${count} item${count !== 1 ? "s" : ""}`,
+ );
} else {
this.editor.setStatus("Failed to open panel");
}
@@ -1180,16 +1197,15 @@ export class Finder {
}
private buildItemEntry(entry: DisplayEntry): TextPropertyEntry {
- const severityIcon =
- entry.severity === "error"
- ? "[E]"
- : entry.severity === "warning"
- ? "[W]"
- : entry.severity === "info"
- ? "[I]"
- : entry.severity === "hint"
- ? "[H]"
- : "";
+ const severityIcon = entry.severity === "error"
+ ? "[E]"
+ : entry.severity === "warning"
+ ? "[W]"
+ : entry.severity === "info"
+ ? "[I]"
+ : entry.severity === "hint"
+ ? "[H]"
+ : "";
const prefix = severityIcon ? `${severityIcon} ` : " ";
const desc = entry.description ? ` ${entry.description}` : "";
@@ -1220,7 +1236,7 @@ export class Finder {
private onPanelSelect(): void {
const itemIndex = this.panelState.lineToItemIndex.get(
- this.panelState.cursorLine
+ this.panelState.cursorLine,
);
if (itemIndex === undefined) {
this.editor.setStatus("No item selected");
@@ -1240,10 +1256,10 @@ export class Finder {
this.editor.openFile(
entry.location.file,
entry.location.line,
- entry.location.column
+ entry.location.column,
);
this.editor.setStatus(
- `Jumped to ${entry.location.file}:${entry.location.line}`
+ `Jumped to ${entry.location.file}:${entry.location.line}`,
);
}
}
@@ -1416,7 +1432,7 @@ export function getRelativePath(editor: EditorAPI, filePath: string): string {
* Create a simple live provider from a getter function
*/
export function createLiveProvider(
- getItems: () => T[]
+ getItems: () => T[],
): FinderProvider & { notify: () => void } {
const listeners: Array<() => void> = [];
diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts
index 5f4de9a8a..baf646ffa 100644
--- a/crates/fresh-editor/plugins/lib/fresh.d.ts
+++ b/crates/fresh-editor/plugins/lib/fresh.d.ts
@@ -1,1191 +1,1302 @@
/**
-* Fresh Editor TypeScript Plugin API
-*
-* This file provides type definitions for the Fresh editor's TypeScript plugin system.
-* Plugins have access to the global `editor` object which provides methods to:
-* - Query editor state (buffers, cursors, viewports)
-* - Modify buffer content (insert, delete text)
-* - Add visual decorations (overlays, highlighting)
-* - Interact with the editor UI (status messages, prompts)
-*
-* AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
-* Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
-*/
+ * Fresh Editor TypeScript Plugin API
+ *
+ * This file provides type definitions for the Fresh editor's TypeScript plugin system.
+ * Plugins have access to the global `editor` object which provides methods to:
+ * - Query editor state (buffers, cursors, viewports)
+ * - Modify buffer content (insert, delete text)
+ * - Add visual decorations (overlays, highlighting)
+ * - Interact with the editor UI (status messages, prompts)
+ *
+ * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
+ * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
+ */
/**
-* Get the editor API instance.
-* Plugins must call this at the top of their file to get a scoped editor object.
-*/
+ * Get the editor API instance.
+ * Plugins must call this at the top of their file to get a scoped editor object.
+ */
declare function getEditor(): EditorAPI;
/** Handle for a cancellable async operation */
interface ProcessHandle extends PromiseLike {
- /** Promise that resolves to the result when complete */
- readonly result: Promise;
- /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
- kill(): Promise;
+ /** Promise that resolves to the result when complete */
+ readonly result: Promise;
+ /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
+ kill(): Promise;
}
/** Buffer identifier */
type BufferId = number;
/** Split identifier */
type SplitId = number;
type TextPropertyEntry = {
- /**
- * Text content for this entry
- */
- text: string;
- /**
- * Optional properties attached to this text (e.g., file path, line number)
- */
- properties?: Record;
+ /**
+ * Text content for this entry
+ */
+ text: string;
+ /**
+ * Optional properties attached to this text (e.g., file path, line number)
+ */
+ properties?: Record;
};
type TsCompositeLayoutConfig = {
- /**
- * Layout type: "side-by-side", "stacked", or "unified"
- */
- type: string;
- /**
- * Width ratios for side-by-side (e.g., [0.5, 0.5])
- */
- ratios: Array | null;
- /**
- * Show separator between panes
- */
- showSeparator: boolean;
- /**
- * Spacing for stacked layout
- */
- spacing: number | null;
+ /**
+ * Layout type: "side-by-side", "stacked", or "unified"
+ */
+ type: string;
+ /**
+ * Width ratios for side-by-side (e.g., [0.5, 0.5])
+ */
+ ratios: Array | null;
+ /**
+ * Show separator between panes
+ */
+ showSeparator: boolean;
+ /**
+ * Spacing for stacked layout
+ */
+ spacing: number | null;
};
type TsCompositeSourceConfig = {
- /**
- * Buffer ID of the source buffer (required)
- */
- bufferId: number;
- /**
- * Label for this pane (e.g., "OLD", "NEW")
- */
- label: string;
- /**
- * Whether this pane is editable
- */
- editable: boolean;
- /**
- * Style configuration
- */
- style: TsCompositePaneStyle | null;
+ /**
+ * Buffer ID of the source buffer (required)
+ */
+ bufferId: number;
+ /**
+ * Label for this pane (e.g., "OLD", "NEW")
+ */
+ label: string;
+ /**
+ * Whether this pane is editable
+ */
+ editable: boolean;
+ /**
+ * Style configuration
+ */
+ style: TsCompositePaneStyle | null;
};
type TsCompositePaneStyle = {
- /**
- * Background color for added lines (RGB)
- * Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
- */
- addBg: [number, number, number] | null;
- /**
- * Background color for removed lines (RGB)
- */
- removeBg: [number, number, number] | null;
- /**
- * Background color for modified lines (RGB)
- */
- modifyBg: [number, number, number] | null;
- /**
- * Gutter style: "line-numbers", "diff-markers", "both", or "none"
- */
- gutterStyle: string | null;
+ /**
+ * Background color for added lines (RGB)
+ * Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
+ */
+ addBg: [number, number, number] | null;
+ /**
+ * Background color for removed lines (RGB)
+ */
+ removeBg: [number, number, number] | null;
+ /**
+ * Background color for modified lines (RGB)
+ */
+ modifyBg: [number, number, number] | null;
+ /**
+ * Gutter style: "line-numbers", "diff-markers", "both", or "none"
+ */
+ gutterStyle: string | null;
};
type TsCompositeHunk = {
- /**
- * Starting line in old buffer (0-indexed)
- */
- oldStart: number;
- /**
- * Number of lines in old buffer
- */
- oldCount: number;
- /**
- * Starting line in new buffer (0-indexed)
- */
- newStart: number;
- /**
- * Number of lines in new buffer
- */
- newCount: number;
+ /**
+ * Starting line in old buffer (0-indexed)
+ */
+ oldStart: number;
+ /**
+ * Number of lines in old buffer
+ */
+ oldCount: number;
+ /**
+ * Starting line in new buffer (0-indexed)
+ */
+ newStart: number;
+ /**
+ * Number of lines in new buffer
+ */
+ newCount: number;
};
type TsCreateCompositeBufferOptions = {
- /**
- * Buffer name (displayed in tabs/title)
- */
- name: string;
- /**
- * Mode for keybindings
- */
- mode: string;
- /**
- * Layout configuration
- */
- layout: TsCompositeLayoutConfig;
- /**
- * Source pane configurations
- */
- sources: Array;
- /**
- * Diff hunks for alignment (optional)
- */
- hunks: Array | null;
+ /**
+ * Buffer name (displayed in tabs/title)
+ */
+ name: string;
+ /**
+ * Mode for keybindings
+ */
+ mode: string;
+ /**
+ * Layout configuration
+ */
+ layout: TsCompositeLayoutConfig;
+ /**
+ * Source pane configurations
+ */
+ sources: Array;
+ /**
+ * Diff hunks for alignment (optional)
+ */
+ hunks: Array | null;
};
type ViewportInfo = {
- /**
- * Byte position of the first visible line
- */
- topByte: number;
- /**
- * Left column offset (horizontal scroll)
- */
- leftColumn: number;
- /**
- * Viewport width
- */
- width: number;
- /**
- * Viewport height
- */
- height: number;
+ /**
+ * Byte position of the first visible line
+ */
+ topByte: number;
+ /**
+ * Left column offset (horizontal scroll)
+ */
+ leftColumn: number;
+ /**
+ * Viewport width
+ */
+ width: number;
+ /**
+ * Viewport height
+ */
+ height: number;
};
type LayoutHints = {
- /**
- * Optional compose width for centering/wrapping
- */
- composeWidth: number | null;
- /**
- * Optional column guides for aligned tables
- */
- columnGuides: Array | null;
+ /**
+ * Optional compose width for centering/wrapping
+ */
+ composeWidth: number | null;
+ /**
+ * Optional column guides for aligned tables
+ */
+ columnGuides: Array | null;
};
type ViewTokenWire = {
- /**
- * Source byte offset in the buffer. None for injected content (annotations).
- */
- source_offset: number | null;
- /**
- * The token content
- */
- kind: ViewTokenWireKind;
- /**
- * Optional styling for injected content (only used when source_offset is None)
- */
- style?: ViewTokenStyle;
-};
-type ViewTokenWireKind = {
- "Text": string;
-} | "Newline" | "Space" | "Break" | {
- "BinaryByte": number;
+ /**
+ * Source byte offset in the buffer. None for injected content (annotations).
+ */
+ source_offset: number | null;
+ /**
+ * The token content
+ */
+ kind: ViewTokenWireKind;
+ /**
+ * Optional styling for injected content (only used when source_offset is None)
+ */
+ style?: ViewTokenStyle;
};
+type ViewTokenWireKind =
+ | {
+ "Text": string;
+ }
+ | "Newline"
+ | "Space"
+ | "Break"
+ | {
+ "BinaryByte": number;
+ };
type ViewTokenStyle = {
- /**
- * Foreground color as RGB tuple
- */
- fg: [number, number, number] | null;
- /**
- * Background color as RGB tuple
- */
- bg: [number, number, number] | null;
- /**
- * Whether to render in bold
- */
- bold: boolean;
- /**
- * Whether to render in italic
- */
- italic: boolean;
+ /**
+ * Foreground color as RGB tuple
+ */
+ fg: [number, number, number] | null;
+ /**
+ * Background color as RGB tuple
+ */
+ bg: [number, number, number] | null;
+ /**
+ * Whether to render in bold
+ */
+ bold: boolean;
+ /**
+ * Whether to render in italic
+ */
+ italic: boolean;
};
type PromptSuggestion = {
- /**
- * The text to display
- */
- text: string;
- /**
- * Optional description
- */
- description?: string;
- /**
- * The value to use when selected (defaults to text if None)
- */
- value?: string;
- /**
- * Whether this suggestion is disabled (greyed out, defaults to false)
- */
- disabled?: boolean;
- /**
- * Optional keyboard shortcut
- */
- keybinding?: string;
+ /**
+ * The text to display
+ */
+ text: string;
+ /**
+ * Optional description
+ */
+ description?: string;
+ /**
+ * The value to use when selected (defaults to text if None)
+ */
+ value?: string;
+ /**
+ * Whether this suggestion is disabled (greyed out, defaults to false)
+ */
+ disabled?: boolean;
+ /**
+ * Optional keyboard shortcut
+ */
+ keybinding?: string;
};
type DirEntry = {
- /**
- * File/directory name
- */
- name: string;
- /**
- * True if this is a file
- */
- is_file: boolean;
- /**
- * True if this is a directory
- */
- is_dir: boolean;
+ /**
+ * File/directory name
+ */
+ name: string;
+ /**
+ * True if this is a file
+ */
+ is_file: boolean;
+ /**
+ * True if this is a directory
+ */
+ is_dir: boolean;
};
type BufferInfo = {
- /**
- * Buffer ID
- */
- id: number;
- /**
- * File path (if any)
- */
- path: string;
- /**
- * Whether the buffer has been modified
- */
- modified: boolean;
- /**
- * Length of buffer in bytes
- */
- length: number;
+ /**
+ * Buffer ID
+ */
+ id: number;
+ /**
+ * File path (if any)
+ */
+ path: string;
+ /**
+ * Whether the buffer has been modified
+ */
+ modified: boolean;
+ /**
+ * Length of buffer in bytes
+ */
+ length: number;
};
type JsDiagnostic = {
- /**
- * Document URI
- */
- uri: string;
- /**
- * Diagnostic message
- */
- message: string;
- /**
- * Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
- */
- severity: number | null;
- /**
- * Range in the document
- */
- range: JsRange;
- /**
- * Source of the diagnostic (e.g., "typescript", "eslint")
- */
- source?: string;
+ /**
+ * Document URI
+ */
+ uri: string;
+ /**
+ * Diagnostic message
+ */
+ message: string;
+ /**
+ * Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
+ */
+ severity: number | null;
+ /**
+ * Range in the document
+ */
+ range: JsRange;
+ /**
+ * Source of the diagnostic (e.g., "typescript", "eslint")
+ */
+ source?: string;
};
type JsRange = {
- /**
- * Start position
- */
- start: JsPosition;
- /**
- * End position
- */
- end: JsPosition;
+ /**
+ * Start position
+ */
+ start: JsPosition;
+ /**
+ * End position
+ */
+ end: JsPosition;
};
type JsPosition = {
- /**
- * Zero-indexed line number
- */
- line: number;
- /**
- * Zero-indexed character offset
- */
- character: number;
+ /**
+ * Zero-indexed line number
+ */
+ line: number;
+ /**
+ * Zero-indexed character offset
+ */
+ character: number;
};
type ActionSpec = {
- /**
- * Action name (e.g., "move_word_right", "delete_line")
- */
- action: string;
- /**
- * Number of times to repeat the action (default 1)
- */
- count: number;
+ /**
+ * Action name (e.g., "move_word_right", "delete_line")
+ */
+ action: string;
+ /**
+ * Number of times to repeat the action (default 1)
+ */
+ count: number;
};
type TsActionPopupAction = {
- /**
- * Unique action identifier (returned in ActionPopupResult)
- */
- id: string;
- /**
- * Display text for the button (can include command hints)
- */
- label: string;
+ /**
+ * Unique action identifier (returned in ActionPopupResult)
+ */
+ id: string;
+ /**
+ * Display text for the button (can include command hints)
+ */
+ label: string;
};
type ActionPopupOptions = {
- /**
- * Unique identifier for the popup (used in ActionPopupResult)
- */
- id: string;
- /**
- * Title text for the popup
- */
- title: string;
- /**
- * Body message (supports basic formatting)
- */
- message: string;
- /**
- * Action buttons to display
- */
- actions: Array;
+ /**
+ * Unique identifier for the popup (used in ActionPopupResult)
+ */
+ id: string;
+ /**
+ * Title text for the popup
+ */
+ title: string;
+ /**
+ * Body message (supports basic formatting)
+ */
+ message: string;
+ /**
+ * Action buttons to display
+ */
+ actions: Array;
};
type FileExplorerDecoration = {
- /**
- * File path to decorate
- */
- path: string;
- /**
- * Symbol to display (e.g., "●", "M", "A")
- */
- symbol: string;
- /**
- * Color as RGB array (rquickjs_serde requires array, not tuple)
- */
- color: [number, number, number];
- /**
- * Priority for display when multiple decorations exist (higher wins)
- */
- priority: number;
+ /**
+ * File path to decorate
+ */
+ path: string;
+ /**
+ * Symbol to display (e.g., "●", "M", "A")
+ */
+ symbol: string;
+ /**
+ * Color as RGB array (rquickjs_serde requires array, not tuple)
+ */
+ color: [number, number, number];
+ /**
+ * Priority for display when multiple decorations exist (higher wins)
+ */
+ priority: number;
};
type FormatterPackConfig = {
- /**
- * Command to run (e.g., "prettier", "rustfmt")
- */
- command: string;
- /**
- * Arguments to pass to the formatter
- */
- args: Array;
+ /**
+ * Command to run (e.g., "prettier", "rustfmt")
+ */
+ command: string;
+ /**
+ * Arguments to pass to the formatter
+ */
+ args: Array;
};
type BackgroundProcessResult = {
- /**
- * Unique process ID for later reference
- */
- process_id: number;
- /**
- * Process exit code (0 usually means success, -1 if killed)
- * Only present when the process has exited
- */
- exit_code: number;
+ /**
+ * Unique process ID for later reference
+ */
+ process_id: number;
+ /**
+ * Process exit code (0 usually means success, -1 if killed)
+ * Only present when the process has exited
+ */
+ exit_code: number;
};
type BufferSavedDiff = {
- equal: boolean;
- byte_ranges: Array<[number, number]>;
- line_ranges: Array<[number, number]> | null;
+ equal: boolean;
+ byte_ranges: Array<[number, number]>;
+ line_ranges: Array<[number, number]> | null;
};
type TsCompositeHunk = {
- /**
- * Starting line in old buffer (0-indexed)
- */
- oldStart: number;
- /**
- * Number of lines in old buffer
- */
- oldCount: number;
- /**
- * Starting line in new buffer (0-indexed)
- */
- newStart: number;
- /**
- * Number of lines in new buffer
- */
- newCount: number;
+ /**
+ * Starting line in old buffer (0-indexed)
+ */
+ oldStart: number;
+ /**
+ * Number of lines in old buffer
+ */
+ oldCount: number;
+ /**
+ * Starting line in new buffer (0-indexed)
+ */
+ newStart: number;
+ /**
+ * Number of lines in new buffer
+ */
+ newCount: number;
};
type TsCreateCompositeBufferOptions = {
- /**
- * Buffer name (displayed in tabs/title)
- */
- name: string;
- /**
- * Mode for keybindings
- */
- mode: string;
- /**
- * Layout configuration
- */
- layout: TsCompositeLayoutConfig;
- /**
- * Source pane configurations
- */
- sources: Array;
- /**
- * Diff hunks for alignment (optional)
- */
- hunks: Array | null;
+ /**
+ * Buffer name (displayed in tabs/title)
+ */
+ name: string;
+ /**
+ * Mode for keybindings
+ */
+ mode: string;
+ /**
+ * Layout configuration
+ */
+ layout: TsCompositeLayoutConfig;
+ /**
+ * Source pane configurations
+ */
+ sources: Array;
+ /**
+ * Diff hunks for alignment (optional)
+ */
+ hunks: Array | null;
};
type CreateVirtualBufferInExistingSplitOptions = {
- /**
- * Buffer name (displayed in tabs/title)
- */
- name: string;
- /**
- * Target split ID (required)
- */
- splitId: number;
- /**
- * Mode for keybindings (e.g., "git-log", "search-results")
- */
- mode?: string;
- /**
- * Whether buffer is read-only (default: false)
- */
- readOnly?: boolean;
- /**
- * Show line numbers in gutter (default: true)
- */
- showLineNumbers?: boolean;
- /**
- * Show cursor (default: true)
- */
- showCursors?: boolean;
- /**
- * Disable text editing (default: false)
- */
- editingDisabled?: boolean;
- /**
- * Enable line wrapping
- */
- lineWrap?: boolean;
- /**
- * Initial content entries with optional properties
- */
- entries?: Array;
+ /**
+ * Buffer name (displayed in tabs/title)
+ */
+ name: string;
+ /**
+ * Target split ID (required)
+ */
+ splitId: number;
+ /**
+ * Mode for keybindings (e.g., "git-log", "search-results")
+ */
+ mode?: string;
+ /**
+ * Whether buffer is read-only (default: false)
+ */
+ readOnly?: boolean;
+ /**
+ * Show line numbers in gutter (default: true)
+ */
+ showLineNumbers?: boolean;
+ /**
+ * Show cursor (default: true)
+ */
+ showCursors?: boolean;
+ /**
+ * Disable text editing (default: false)
+ */
+ editingDisabled?: boolean;
+ /**
+ * Enable line wrapping
+ */
+ lineWrap?: boolean;
+ /**
+ * Initial content entries with optional properties
+ */
+ entries?: Array;
};
type CreateVirtualBufferInSplitOptions = {
- /**
- * Buffer name (displayed in tabs/title)
- */
- name: string;
- /**
- * Mode for keybindings (e.g., "git-log", "search-results")
- */
- mode?: string;
- /**
- * Whether buffer is read-only (default: false)
- */
- readOnly?: boolean;
- /**
- * Split ratio 0.0-1.0 (default: 0.5)
- */
- ratio?: number;
- /**
- * Split direction: "horizontal" or "vertical"
- */
- direction?: string;
- /**
- * Panel ID to split from
- */
- panelId?: string;
- /**
- * Show line numbers in gutter (default: true)
- */
- showLineNumbers?: boolean;
- /**
- * Show cursor (default: true)
- */
- showCursors?: boolean;
- /**
- * Disable text editing (default: false)
- */
- editingDisabled?: boolean;
- /**
- * Enable line wrapping
- */
- lineWrap?: boolean;
- /**
- * Initial content entries with optional properties
- */
- entries?: Array;
+ /**
+ * Buffer name (displayed in tabs/title)
+ */
+ name: string;
+ /**
+ * Mode for keybindings (e.g., "git-log", "search-results")
+ */
+ mode?: string;
+ /**
+ * Whether buffer is read-only (default: false)
+ */
+ readOnly?: boolean;
+ /**
+ * Split ratio 0.0-1.0 (default: 0.5)
+ */
+ ratio?: number;
+ /**
+ * Split direction: "horizontal" or "vertical"
+ */
+ direction?: string;
+ /**
+ * Panel ID to split from
+ */
+ panelId?: string;
+ /**
+ * Show line numbers in gutter (default: true)
+ */
+ showLineNumbers?: boolean;
+ /**
+ * Show cursor (default: true)
+ */
+ showCursors?: boolean;
+ /**
+ * Disable text editing (default: false)
+ */
+ editingDisabled?: boolean;
+ /**
+ * Enable line wrapping
+ */
+ lineWrap?: boolean;
+ /**
+ * Initial content entries with optional properties
+ */
+ entries?: Array;
};
type CreateVirtualBufferOptions = {
- /**
- * Buffer name (displayed in tabs/title)
- */
- name: string;
- /**
- * Mode for keybindings (e.g., "git-log", "search-results")
- */
- mode?: string;
- /**
- * Whether buffer is read-only (default: false)
- */
- readOnly?: boolean;
- /**
- * Show line numbers in gutter (default: false)
- */
- showLineNumbers?: boolean;
- /**
- * Show cursor (default: true)
- */
- showCursors?: boolean;
- /**
- * Disable text editing (default: false)
- */
- editingDisabled?: boolean;
- /**
- * Hide from tab bar (default: false)
- */
- hiddenFromTabs?: boolean;
- /**
- * Initial content entries with optional properties
- */
- entries?: Array;
+ /**
+ * Buffer name (displayed in tabs/title)
+ */
+ name: string;
+ /**
+ * Mode for keybindings (e.g., "git-log", "search-results")
+ */
+ mode?: string;
+ /**
+ * Whether buffer is read-only (default: false)
+ */
+ readOnly?: boolean;
+ /**
+ * Show line numbers in gutter (default: false)
+ */
+ showLineNumbers?: boolean;
+ /**
+ * Show cursor (default: true)
+ */
+ showCursors?: boolean;
+ /**
+ * Disable text editing (default: false)
+ */
+ editingDisabled?: boolean;
+ /**
+ * Hide from tab bar (default: false)
+ */
+ hiddenFromTabs?: boolean;
+ /**
+ * Initial content entries with optional properties
+ */
+ entries?: Array;
};
type LanguagePackConfig = {
- /**
- * Comment prefix for line comments (e.g., "//" or "#")
- */
- commentPrefix: string | null;
- /**
- * Block comment start marker (e.g., slash-star)
- */
- blockCommentStart: string | null;
- /**
- * Block comment end marker (e.g., star-slash)
- */
- blockCommentEnd: string | null;
- /**
- * Whether to use tabs instead of spaces for indentation
- */
- useTabs: boolean | null;
- /**
- * Tab size (number of spaces per tab level)
- */
- tabSize: number | null;
- /**
- * Whether auto-indent is enabled
- */
- autoIndent: boolean | null;
- /**
- * Whether to show whitespace tab indicators (→) for this language
- * Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
- */
- showWhitespaceTabs: boolean | null;
- /**
- * Formatter configuration
- */
- formatter: FormatterPackConfig | null;
+ /**
+ * Comment prefix for line comments (e.g., "//" or "#")
+ */
+ commentPrefix: string | null;
+ /**
+ * Block comment start marker (e.g., slash-star)
+ */
+ blockCommentStart: string | null;
+ /**
+ * Block comment end marker (e.g., star-slash)
+ */
+ blockCommentEnd: string | null;
+ /**
+ * Whether to use tabs instead of spaces for indentation
+ */
+ useTabs: boolean | null;
+ /**
+ * Tab size (number of spaces per tab level)
+ */
+ tabSize: number | null;
+ /**
+ * Whether auto-indent is enabled
+ */
+ autoIndent: boolean | null;
+ /**
+ * Whether to show whitespace tab indicators (→) for this language
+ * Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
+ */
+ showWhitespaceTabs: boolean | null;
+ /**
+ * Formatter configuration
+ */
+ formatter: FormatterPackConfig | null;
};
type LspServerPackConfig = {
- /**
- * Command to start the LSP server
- */
- command: string;
- /**
- * Arguments to pass to the command
- */
- args: Array;
- /**
- * Whether to auto-start the server when a matching file is opened
- */
- autoStart: boolean | null;
- /**
- * LSP initialization options
- */
- initializationOptions: Record | null;
+ /**
+ * Command to start the LSP server
+ */
+ command: string;
+ /**
+ * Arguments to pass to the command
+ */
+ args: Array;
+ /**
+ * Whether to auto-start the server when a matching file is opened
+ */
+ autoStart: boolean | null;
+ /**
+ * LSP initialization options
+ */
+ initializationOptions: Record | null;
};
type SpawnResult = {
- /**
- * Complete stdout as string
- */
- stdout: string;
- /**
- * Complete stderr as string
- */
- stderr: string;
- /**
- * Process exit code (0 usually means success, -1 if killed)
- */
- exit_code: number;
+ /**
+ * Complete stdout as string
+ */
+ stdout: string;
+ /**
+ * Complete stderr as string
+ */
+ stderr: string;
+ /**
+ * Process exit code (0 usually means success, -1 if killed)
+ */
+ exit_code: number;
};
type PromptSuggestion = {
- /**
- * The text to display
- */
- text: string;
- /**
- * Optional description
- */
- description?: string;
- /**
- * The value to use when selected (defaults to text if None)
- */
- value?: string;
- /**
- * Whether this suggestion is disabled (greyed out, defaults to false)
- */
- disabled?: boolean;
- /**
- * Optional keyboard shortcut
- */
- keybinding?: string;
+ /**
+ * The text to display
+ */
+ text: string;
+ /**
+ * Optional description
+ */
+ description?: string;
+ /**
+ * The value to use when selected (defaults to text if None)
+ */
+ value?: string;
+ /**
+ * Whether this suggestion is disabled (greyed out, defaults to false)
+ */
+ disabled?: boolean;
+ /**
+ * Optional keyboard shortcut
+ */
+ keybinding?: string;
};
type TextPropertiesAtCursor = Array>;
type TsHighlightSpan = {
- start: number;
- end: number;
- color: [number, number, number];
- bold: boolean;
- italic: boolean;
+ start: number;
+ end: number;
+ color: [number, number, number];
+ bold: boolean;
+ italic: boolean;
};
type VirtualBufferResult = {
- /**
- * The created buffer ID
- */
- bufferId: number;
- /**
- * The split ID (if created in a new split)
- */
- splitId: number | null;
+ /**
+ * The created buffer ID
+ */
+ bufferId: number;
+ /**
+ * The split ID (if created in a new split)
+ */
+ splitId: number | null;
};
/**
-* Main editor API interface
-*/
+ * Main editor API interface
+ */
interface EditorAPI {
- /**
- * Get the active buffer ID (0 if none)
- */
- getActiveBufferId(): number;
- /**
- * Get the active split ID
- */
- getActiveSplitId(): number;
- /**
- * List all open buffers - returns array of BufferInfo objects
- */
- listBuffers(): BufferInfo[];
- debug(msg: string): void;
- info(msg: string): void;
- warn(msg: string): void;
- error(msg: string): void;
- setStatus(msg: string): void;
- copyToClipboard(text: string): void;
- setClipboard(text: string): void;
- /**
- * Register a command - reads plugin name from __pluginName__ global
- * context is optional - can be omitted, null, undefined, or a string
- */
- registerCommand(name: string, description: string, handlerName: string, context?: unknown): boolean;
- /**
- * Unregister a command by name
- */
- unregisterCommand(name: string): boolean;
- /**
- * Set a context (for keybinding conditions)
- */
- setContext(name: string, active: boolean): boolean;
- /**
- * Execute a built-in action
- */
- executeAction(actionName: string): boolean;
- /**
- * Translate a string - reads plugin name from __pluginName__ global
- * Args is optional - can be omitted, undefined, null, or an object
- */
- t(key: string, ...args: unknown[]): string;
- /**
- * Get cursor position in active buffer
- */
- getCursorPosition(): number;
- /**
- * Get file path for a buffer
- */
- getBufferPath(bufferId: number): string;
- /**
- * Get buffer length in bytes
- */
- getBufferLength(bufferId: number): number;
- /**
- * Check if buffer has unsaved changes
- */
- isBufferModified(bufferId: number): boolean;
- /**
- * Save a buffer to a specific file path
- * Used by :w filename to save unnamed buffers or save-as
- */
- saveBufferToPath(bufferId: number, path: string): boolean;
- /**
- * Get buffer info by ID
- */
- getBufferInfo(bufferId: number): BufferInfo | null;
- /**
- * Get primary cursor info for active buffer
- */
- getPrimaryCursor(): unknown;
- /**
- * Get all cursors for active buffer
- */
- getAllCursors(): unknown;
- /**
- * Get all cursor positions as byte offsets
- */
- getAllCursorPositions(): unknown;
- /**
- * Get viewport info for active buffer
- */
- getViewport(): ViewportInfo | null;
- /**
- * Get the line number (0-indexed) of the primary cursor
- */
- getCursorLine(): number;
- /**
- * Get the byte offset of the start of a line (0-indexed line number)
- * Returns null if the line number is out of range
- */
- getLineStartPosition(line: number): Promise;
- /**
- * Find buffer by file path, returns buffer ID or 0 if not found
- */
- findBufferByPath(path: string): number;
- /**
- * Get diff between buffer content and last saved version
- */
- getBufferSavedDiff(bufferId: number): BufferSavedDiff | null;
- /**
- * Insert text at a position in a buffer
- */
- insertText(bufferId: number, position: number, text: string): boolean;
- /**
- * Delete a range from a buffer
- */
- deleteRange(bufferId: number, start: number, end: number): boolean;
- /**
- * Insert text at cursor position in active buffer
- */
- insertAtCursor(text: string): boolean;
- /**
- * Open a file, optionally at a specific line/column
- */
- openFile(path: string, line: number | null, column: number | null): boolean;
- /**
- * Open a file in a specific split
- */
- openFileInSplit(splitId: number, path: string, line: number, column: number): boolean;
- /**
- * Show a buffer in the current split
- */
- showBuffer(bufferId: number): boolean;
- /**
- * Close a buffer
- */
- closeBuffer(bufferId: number): boolean;
- /**
- * Subscribe to an editor event
- */
- on(eventName: string, handlerName: string): void;
- /**
- * Unsubscribe from an event
- */
- off(eventName: string, handlerName: string): void;
- /**
- * Get an environment variable
- */
- getEnv(name: string): string | null;
- /**
- * Get current working directory
- */
- getCwd(): string;
- /**
- * Join path components (variadic - accepts multiple string arguments)
- * Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join)
- */
- pathJoin(...parts: string[]): string;
- /**
- * Get directory name from path
- */
- pathDirname(path: string): string;
- /**
- * Get file name from path
- */
- pathBasename(path: string): string;
- /**
- * Get file extension
- */
- pathExtname(path: string): string;
- /**
- * Check if path is absolute
- */
- pathIsAbsolute(path: string): boolean;
- /**
- * Check if file exists
- */
- fileExists(path: string): boolean;
- /**
- * Read file contents
- */
- readFile(path: string): string | null;
- /**
- * Write file contents
- */
- writeFile(path: string, content: string): boolean;
- /**
- * Read directory contents (returns array of {name, is_file, is_dir})
- */
- readDir(path: string): DirEntry[];
- /**
- * Get current config as JS object
- */
- getConfig(): unknown;
- /**
- * Get user config as JS object
- */
- getUserConfig(): unknown;
- /**
- * Reload configuration from file
- */
- reloadConfig(): void;
- /**
- * Reload theme registry from disk
- * Call this after installing theme packages or saving new themes
- */
- reloadThemes(): void;
- /**
- * Register a TextMate grammar file for a language
- * The grammar will be pending until reload_grammars() is called
- */
- registerGrammar(language: string, grammarPath: string, extensions: string[]): boolean;
- /**
- * Register language configuration (comment prefix, indentation, formatter)
- */
- registerLanguageConfig(language: string, config: LanguagePackConfig): boolean;
- /**
- * Register an LSP server for a language
- */
- registerLspServer(language: string, config: LspServerPackConfig): boolean;
- /**
- * Reload the grammar registry to apply registered grammars
- * Call this after registering one or more grammars
- */
- reloadGrammars(): void;
- /**
- * Get config directory path
- */
- getConfigDir(): string;
- /**
- * Get themes directory path
- */
- getThemesDir(): string;
- /**
- * Apply a theme by name
- */
- applyTheme(themeName: string): boolean;
- /**
- * Get theme schema as JS object
- */
- getThemeSchema(): unknown;
- /**
- * Get list of builtin themes as JS object
- */
- getBuiltinThemes(): unknown;
- /**
- * Delete a custom theme (alias for deleteThemeSync)
- */
- deleteTheme(name: string): boolean;
- /**
- * Get file stat information
- */
- fileStat(path: string): unknown;
- /**
- * Check if a background process is still running
- */
- isProcessRunning(ProcessId: number): boolean;
- /**
- * Kill a process by ID (alias for killBackgroundProcess)
- */
- killProcess(processId: number): boolean;
- /**
- * Translate a key for a specific plugin
- */
- pluginTranslate(pluginName: string, key: string, args?: Record): string;
- /**
- * Create a composite buffer (async)
- *
- * Uses typed CreateCompositeBufferOptions - serde validates field names at runtime
- * via `deny_unknown_fields` attribute
- */
- createCompositeBuffer(opts: CreateCompositeBufferOptions): Promise;
- /**
- * Update alignment hunks for a composite buffer
- *
- * Uses typed Vec - serde validates field names at runtime
- */
- updateCompositeAlignment(bufferId: number, hunks: CompositeHunk[]): boolean;
- /**
- * Close a composite buffer
- */
- closeCompositeBuffer(bufferId: number): boolean;
- /**
- * Request syntax highlights for a buffer range (async)
- */
- getHighlights(bufferId: number, start: number, end: number): Promise;
- /**
- * Add an overlay with styling options
- *
- * Colors can be specified as RGB arrays `[r, g, b]` or theme key strings.
- * Theme keys are resolved at render time, so overlays update with theme changes.
- *
- * Theme key examples: "ui.status_bar_fg", "editor.selection_bg", "syntax.keyword"
- *
- * Example usage in TypeScript:
- * ```typescript
- * editor.addOverlay(bufferId, "my-namespace", 0, 10, {
- * fg: "syntax.keyword", // theme key
- * bg: [40, 40, 50], // RGB array
- * bold: true,
- * });
- * ```
- */
- addOverlay(bufferId: number, namespace: string, start: number, end: number, options: Record): boolean;
- /**
- * Clear all overlays in a namespace
- */
- clearNamespace(bufferId: number, namespace: string): boolean;
- /**
- * Clear all overlays from a buffer
- */
- clearAllOverlays(bufferId: number): boolean;
- /**
- * Clear all overlays that overlap with a byte range
- */
- clearOverlaysInRange(bufferId: number, start: number, end: number): boolean;
- /**
- * Remove an overlay by its handle
- */
- removeOverlay(bufferId: number, handle: string): boolean;
- /**
- * Submit a view transform for a buffer/split
- *
- * Note: tokens should be ViewTokenWire[], layoutHints should be LayoutHints
- * These use manual parsing due to complex enum handling
- */
- submitViewTransform(bufferId: number, splitId: number | null, start: number, end: number, tokens: Record[], LayoutHints?: Record): boolean;
- /**
- * Clear view transform for a buffer/split
- */
- clearViewTransform(bufferId: number, splitId: number | null): boolean;
- /**
- * Set file explorer decorations for a namespace
- */
- setFileExplorerDecorations(namespace: string, decorations: Record[]): boolean;
- /**
- * Clear file explorer decorations for a namespace
- */
- clearFileExplorerDecorations(namespace: string): boolean;
- /**
- * Add virtual text (inline text that doesn't exist in the buffer)
- */
- addVirtualText(bufferId: number, virtualTextId: string, position: number, text: string, r: number, g: number, b: number, before: boolean, useBg: boolean): boolean;
- /**
- * Remove a virtual text by ID
- */
- removeVirtualText(bufferId: number, virtualTextId: string): boolean;
- /**
- * Remove virtual texts whose ID starts with the given prefix
- */
- removeVirtualTextsByPrefix(bufferId: number, prefix: string): boolean;
- /**
- * Clear all virtual texts from a buffer
- */
- clearVirtualTexts(bufferId: number): boolean;
- /**
- * Clear all virtual texts in a namespace
- */
- clearVirtualTextNamespace(bufferId: number, namespace: string): boolean;
- /**
- * Add a virtual line (full line above/below a position)
- */
- addVirtualLine(bufferId: number, position: number, text: string, fgR: number, fgG: number, fgB: number, bgR: number, bgG: number, bgB: number, above: boolean, namespace: string, priority: number): boolean;
- /**
- * Show a prompt and wait for user input (async)
- * Returns the user input or null if cancelled
- */
- prompt(label: string, initialValue: string): Promise;
- /**
- * Start an interactive prompt
- */
- startPrompt(label: string, promptType: string): boolean;
- /**
- * Start a prompt with initial value
- */
- startPromptWithInitial(label: string, promptType: string, initialValue: string): boolean;
- /**
- * Set suggestions for the current prompt
- *
- * Uses typed Vec - serde validates field names at runtime
- */
- setPromptSuggestions(suggestions: Suggestion[]): boolean;
- /**
- * Define a buffer mode (takes bindings as array of [key, command] pairs)
- */
- defineMode(name: string, parent: string | null, bindingsArr: string[][], readOnly?: boolean): boolean;
- /**
- * Set the global editor mode
- */
- setEditorMode(mode: string | null): boolean;
- /**
- * Get the current editor mode
- */
- getEditorMode(): string | null;
- /**
- * Close a split
- */
- closeSplit(splitId: number): boolean;
- /**
- * Set the buffer displayed in a split
- */
- setSplitBuffer(splitId: number, bufferId: number): boolean;
- /**
- * Focus a specific split
- */
- focusSplit(splitId: number): boolean;
- /**
- * Set scroll position of a split
- */
- setSplitScroll(splitId: number, topByte: number): boolean;
- /**
- * Set the ratio of a split (0.0 to 1.0, 0.5 = equal)
- */
- setSplitRatio(splitId: number, ratio: number): boolean;
- /**
- * Distribute all splits evenly
- */
- distributeSplitsEvenly(): boolean;
- /**
- * Set cursor position in a buffer
- */
- setBufferCursor(bufferId: number, position: number): boolean;
- /**
- * Set a line indicator in the gutter
- */
- setLineIndicator(bufferId: number, line: number, namespace: string, symbol: string, r: number, g: number, b: number, priority: number): boolean;
- /**
- * Clear line indicators in a namespace
- */
- clearLineIndicators(bufferId: number, namespace: string): boolean;
- /**
- * Enable or disable line numbers for a buffer
- */
- setLineNumbers(bufferId: number, enabled: boolean): boolean;
- /**
- * Create a scroll sync group for anchor-based synchronized scrolling
- */
- createScrollSyncGroup(groupId: number, leftSplit: number, rightSplit: number): boolean;
- /**
- * Set sync anchors for a scroll sync group
- */
- setScrollSyncAnchors(groupId: number, anchors: number[][]): boolean;
- /**
- * Remove a scroll sync group
- */
- removeScrollSyncGroup(groupId: number): boolean;
- /**
- * Execute multiple actions in sequence
- *
- * Takes typed ActionSpec array - serde validates field names at runtime
- */
- executeActions(actions: ActionSpec[]): boolean;
- /**
- * Show an action popup
- *
- * Takes a typed ActionPopupOptions struct - serde validates field names at runtime
- */
- showActionPopup(opts: ActionPopupOptions): boolean;
- /**
- * Disable LSP for a specific language
- */
- disableLspForLanguage(language: string): boolean;
- /**
- * Set the workspace root URI for a specific language's LSP server
- * This allows plugins to specify project roots (e.g., directory containing .csproj)
- */
- setLspRootUri(language: string, uri: string): boolean;
- /**
- * Get all diagnostics from LSP
- */
- getAllDiagnostics(): JsDiagnostic[];
- /**
- * Get registered event handlers for an event
- */
- getHandlers(eventName: string): string[];
- /**
- * Create a virtual buffer in current split (async, returns buffer and split IDs)
- */
- createVirtualBuffer(opts: CreateVirtualBufferOptions): Promise;
- /**
- * Create a virtual buffer in a new split (async, returns buffer and split IDs)
- */
- createVirtualBufferInSplit(opts: CreateVirtualBufferInSplitOptions): Promise;
- /**
- * Create a virtual buffer in an existing split (async, returns buffer and split IDs)
- */
- createVirtualBufferInExistingSplit(opts: CreateVirtualBufferInExistingSplitOptions): Promise;
- /**
- * Set virtual buffer content (takes array of entry objects)
- *
- * Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support
- */
- setVirtualBufferContent(bufferId: number, entriesArr: Record[]): boolean;
- /**
- * Get text properties at cursor position (returns JS array)
- */
- getTextPropertiesAtCursor(bufferId: number): TextPropertiesAtCursor;
- /**
- * Spawn a process (async, returns request_id)
- */
- spawnProcess(command: string, args: string[], cwd?: string): ProcessHandle;
- /**
- * Wait for a process to complete and get its result (async)
- */
- spawnProcessWait(processId: number): Promise;
- /**
- * Get buffer text range (async, returns request_id)
- */
- getBufferText(bufferId: number, start: number, end: number): Promise;
- /**
- * Delay/sleep (async, returns request_id)
- */
- delay(durationMs: number): Promise;
- /**
- * Send LSP request (async, returns request_id)
- */
- sendLspRequest(language: string, method: string, params: Record | null): Promise;
- /**
- * Spawn a background process (async, returns request_id which is also process_id)
- */
- spawnBackgroundProcess(command: string, args: string[], cwd?: string): ProcessHandle;
- /**
- * Kill a background process
- */
- killBackgroundProcess(processId: number): boolean;
- /**
- * Force refresh of line display
- */
- refreshLines(bufferId: number): boolean;
- /**
- * Get the current locale
- */
- getCurrentLocale(): string;
- /**
- * Load a plugin from a file path (async)
- */
- loadPlugin(path: string): Promise;
- /**
- * Unload a plugin by name (async)
- */
- unloadPlugin(name: string): Promise;
- /**
- * Reload a plugin by name (async)
- */
- reloadPlugin(name: string): Promise;
- /**
- * List all loaded plugins (async)
- * Returns array of { name: string, path: string, enabled: boolean }
- */
- listPlugins(): Promise>;
+ /**
+ * Get the active buffer ID (0 if none)
+ */
+ getActiveBufferId(): number;
+ /**
+ * Get the active split ID
+ */
+ getActiveSplitId(): number;
+ /**
+ * List all open buffers - returns array of BufferInfo objects
+ */
+ listBuffers(): BufferInfo[];
+ debug(msg: string): void;
+ info(msg: string): void;
+ warn(msg: string): void;
+ error(msg: string): void;
+ setStatus(msg: string): void;
+ copyToClipboard(text: string): void;
+ setClipboard(text: string): void;
+ /**
+ * Register a command - reads plugin name from __pluginName__ global
+ * context is optional - can be omitted, null, undefined, or a string
+ */
+ registerCommand(
+ name: string,
+ description: string,
+ handlerName: string,
+ context?: unknown,
+ ): boolean;
+ /**
+ * Unregister a command by name
+ */
+ unregisterCommand(name: string): boolean;
+ /**
+ * Set a context (for keybinding conditions)
+ */
+ setContext(name: string, active: boolean): boolean;
+ /**
+ * Execute a built-in action
+ */
+ executeAction(actionName: string): boolean;
+ /**
+ * Translate a string - reads plugin name from __pluginName__ global
+ * Args is optional - can be omitted, undefined, null, or an object
+ */
+ t(key: string, ...args: unknown[]): string;
+ /**
+ * Get cursor position in active buffer
+ */
+ getCursorPosition(): number;
+ /**
+ * Get file path for a buffer
+ */
+ getBufferPath(bufferId: number): string;
+ /**
+ * Get buffer length in bytes
+ */
+ getBufferLength(bufferId: number): number;
+ /**
+ * Check if buffer has unsaved changes
+ */
+ isBufferModified(bufferId: number): boolean;
+ /**
+ * Save a buffer to a specific file path
+ * Used by :w filename to save unnamed buffers or save-as
+ */
+ saveBufferToPath(bufferId: number, path: string): boolean;
+ /**
+ * Get buffer info by ID
+ */
+ getBufferInfo(bufferId: number): BufferInfo | null;
+ /**
+ * Get primary cursor info for active buffer
+ */
+ getPrimaryCursor(): unknown;
+ /**
+ * Get all cursors for active buffer
+ */
+ getAllCursors(): unknown;
+ /**
+ * Get all cursor positions as byte offsets
+ */
+ getAllCursorPositions(): unknown;
+ /**
+ * Get viewport info for active buffer
+ */
+ getViewport(): ViewportInfo | null;
+ /**
+ * Get the line number (0-indexed) of the primary cursor
+ */
+ getCursorLine(): number;
+ /**
+ * Get the byte offset of the start of a line (0-indexed line number)
+ * Returns null if the line number is out of range
+ */
+ getLineStartPosition(line: number): Promise;
+ /**
+ * Find buffer by file path, returns buffer ID or 0 if not found
+ */
+ findBufferByPath(path: string): number;
+ /**
+ * Get diff between buffer content and last saved version
+ */
+ getBufferSavedDiff(bufferId: number): BufferSavedDiff | null;
+ /**
+ * Insert text at a position in a buffer
+ */
+ insertText(bufferId: number, position: number, text: string): boolean;
+ /**
+ * Delete a range from a buffer
+ */
+ deleteRange(bufferId: number, start: number, end: number): boolean;
+ /**
+ * Insert text at cursor position in active buffer
+ */
+ insertAtCursor(text: string): boolean;
+ /**
+ * Open a file, optionally at a specific line/column
+ */
+ openFile(path: string, line: number | null, column: number | null): boolean;
+ /**
+ * Open a file in a specific split
+ */
+ openFileInSplit(
+ splitId: number,
+ path: string,
+ line: number,
+ column: number,
+ ): boolean;
+ /**
+ * Show a buffer in the current split
+ */
+ showBuffer(bufferId: number): boolean;
+ /**
+ * Close a buffer
+ */
+ closeBuffer(bufferId: number): boolean;
+ /**
+ * Subscribe to an editor event
+ */
+ on(eventName: string, handlerName: string): void;
+ /**
+ * Unsubscribe from an event
+ */
+ off(eventName: string, handlerName: string): void;
+ /**
+ * Get an environment variable
+ */
+ getEnv(name: string): string | null;
+ /**
+ * Get current working directory
+ */
+ getCwd(): string;
+ /**
+ * Join path components (variadic - accepts multiple string arguments)
+ * Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join)
+ */
+ pathJoin(...parts: string[]): string;
+ /**
+ * Get directory name from path
+ */
+ pathDirname(path: string): string;
+ /**
+ * Get file name from path
+ */
+ pathBasename(path: string): string;
+ /**
+ * Get file extension
+ */
+ pathExtname(path: string): string;
+ /**
+ * Check if path is absolute
+ */
+ pathIsAbsolute(path: string): boolean;
+ /**
+ * Check if file exists
+ */
+ fileExists(path: string): boolean;
+ /**
+ * Read file contents
+ */
+ readFile(path: string): string | null;
+ /**
+ * Write file contents
+ */
+ writeFile(path: string, content: string): boolean;
+ /**
+ * Read directory contents (returns array of {name, is_file, is_dir})
+ */
+ readDir(path: string): DirEntry[];
+ /**
+ * Get current config as JS object
+ */
+ getConfig(): unknown;
+ /**
+ * Get user config as JS object
+ */
+ getUserConfig(): unknown;
+ /**
+ * Reload configuration from file
+ */
+ reloadConfig(): void;
+ /**
+ * Reload theme registry from disk
+ * Call this after installing theme packages or saving new themes
+ */
+ reloadThemes(): void;
+ /**
+ * Register a TextMate grammar file for a language
+ * The grammar will be pending until reload_grammars() is called
+ */
+ registerGrammar(
+ language: string,
+ grammarPath: string,
+ extensions: string[],
+ ): boolean;
+ /**
+ * Register language configuration (comment prefix, indentation, formatter)
+ */
+ registerLanguageConfig(language: string, config: LanguagePackConfig): boolean;
+ /**
+ * Register an LSP server for a language
+ */
+ registerLspServer(language: string, config: LspServerPackConfig): boolean;
+ /**
+ * Reload the grammar registry to apply registered grammars
+ * Call this after registering one or more grammars
+ */
+ reloadGrammars(): void;
+ /**
+ * Get config directory path
+ */
+ getConfigDir(): string;
+ /**
+ * Get themes directory path
+ */
+ getThemesDir(): string;
+ /**
+ * Apply a theme by name
+ */
+ applyTheme(themeName: string): boolean;
+ /**
+ * Get theme schema as JS object
+ */
+ getThemeSchema(): unknown;
+ /**
+ * Get list of builtin themes as JS object
+ */
+ getBuiltinThemes(): unknown;
+ /**
+ * Delete a custom theme (alias for deleteThemeSync)
+ */
+ deleteTheme(name: string): boolean;
+ /**
+ * Get file stat information
+ */
+ fileStat(path: string): unknown;
+ /**
+ * Check if a background process is still running
+ */
+ isProcessRunning(ProcessId: number): boolean;
+ /**
+ * Kill a process by ID (alias for killBackgroundProcess)
+ */
+ killProcess(processId: number): boolean;
+ /**
+ * Translate a key for a specific plugin
+ */
+ pluginTranslate(
+ pluginName: string,
+ key: string,
+ args?: Record,
+ ): string;
+ /**
+ * Create a composite buffer (async)
+ *
+ * Uses typed CreateCompositeBufferOptions - serde validates field names at runtime
+ * via `deny_unknown_fields` attribute
+ */
+ createCompositeBuffer(opts: CreateCompositeBufferOptions): Promise;
+ /**
+ * Update alignment hunks for a composite buffer
+ *
+ * Uses typed Vec - serde validates field names at runtime
+ */
+ updateCompositeAlignment(bufferId: number, hunks: CompositeHunk[]): boolean;
+ /**
+ * Close a composite buffer
+ */
+ closeCompositeBuffer(bufferId: number): boolean;
+ /**
+ * Request syntax highlights for a buffer range (async)
+ */
+ getHighlights(
+ bufferId: number,
+ start: number,
+ end: number,
+ ): Promise;
+ /**
+ * Add an overlay with styling options
+ *
+ * Colors can be specified as RGB arrays `[r, g, b]` or theme key strings.
+ * Theme keys are resolved at render time, so overlays update with theme changes.
+ *
+ * Theme key examples: "ui.status_bar_fg", "editor.selection_bg", "syntax.keyword"
+ *
+ * Example usage in TypeScript:
+ * ```typescript
+ * editor.addOverlay(bufferId, "my-namespace", 0, 10, {
+ * fg: "syntax.keyword", // theme key
+ * bg: [40, 40, 50], // RGB array
+ * bold: true,
+ * });
+ * ```
+ */
+ addOverlay(
+ bufferId: number,
+ namespace: string,
+ start: number,
+ end: number,
+ options: Record,
+ ): boolean;
+ /**
+ * Clear all overlays in a namespace
+ */
+ clearNamespace(bufferId: number, namespace: string): boolean;
+ /**
+ * Clear all overlays from a buffer
+ */
+ clearAllOverlays(bufferId: number): boolean;
+ /**
+ * Clear all overlays that overlap with a byte range
+ */
+ clearOverlaysInRange(bufferId: number, start: number, end: number): boolean;
+ /**
+ * Remove an overlay by its handle
+ */
+ removeOverlay(bufferId: number, handle: string): boolean;
+ /**
+ * Submit a view transform for a buffer/split
+ *
+ * Note: tokens should be ViewTokenWire[], layoutHints should be LayoutHints
+ * These use manual parsing due to complex enum handling
+ */
+ submitViewTransform(
+ bufferId: number,
+ splitId: number | null,
+ start: number,
+ end: number,
+ tokens: Record[],
+ LayoutHints?: Record,
+ ): boolean;
+ /**
+ * Clear view transform for a buffer/split
+ */
+ clearViewTransform(bufferId: number, splitId: number | null): boolean;
+ /**
+ * Set file explorer decorations for a namespace
+ */
+ setFileExplorerDecorations(
+ namespace: string,
+ decorations: Record[],
+ ): boolean;
+ /**
+ * Clear file explorer decorations for a namespace
+ */
+ clearFileExplorerDecorations(namespace: string): boolean;
+ /**
+ * Add virtual text (inline text that doesn't exist in the buffer)
+ */
+ addVirtualText(
+ bufferId: number,
+ virtualTextId: string,
+ position: number,
+ text: string,
+ r: number,
+ g: number,
+ b: number,
+ before: boolean,
+ useBg: boolean,
+ ): boolean;
+ /**
+ * Remove a virtual text by ID
+ */
+ removeVirtualText(bufferId: number, virtualTextId: string): boolean;
+ /**
+ * Remove virtual texts whose ID starts with the given prefix
+ */
+ removeVirtualTextsByPrefix(bufferId: number, prefix: string): boolean;
+ /**
+ * Clear all virtual texts from a buffer
+ */
+ clearVirtualTexts(bufferId: number): boolean;
+ /**
+ * Clear all virtual texts in a namespace
+ */
+ clearVirtualTextNamespace(bufferId: number, namespace: string): boolean;
+ /**
+ * Add a virtual line (full line above/below a position)
+ */
+ addVirtualLine(
+ bufferId: number,
+ position: number,
+ text: string,
+ fgR: number,
+ fgG: number,
+ fgB: number,
+ bgR: number,
+ bgG: number,
+ bgB: number,
+ above: boolean,
+ namespace: string,
+ priority: number,
+ ): boolean;
+ /**
+ * Show a prompt and wait for user input (async)
+ * Returns the user input or null if cancelled
+ */
+ prompt(label: string, initialValue: string): Promise;
+ /**
+ * Start an interactive prompt
+ */
+ startPrompt(label: string, promptType: string): boolean;
+ /**
+ * Start a prompt with initial value
+ */
+ startPromptWithInitial(
+ label: string,
+ promptType: string,
+ initialValue: string,
+ ): boolean;
+ /**
+ * Set suggestions for the current prompt
+ *
+ * Uses typed Vec - serde validates field names at runtime
+ */
+ setPromptSuggestions(suggestions: Suggestion[]): boolean;
+ /**
+ * Define a buffer mode (takes bindings as array of [key, command] pairs)
+ */
+ defineMode(
+ name: string,
+ parent: string | null,
+ bindingsArr: string[][],
+ readOnly?: boolean,
+ ): boolean;
+ /**
+ * Set the global editor mode
+ */
+ setEditorMode(mode: string | null): boolean;
+ /**
+ * Get the current editor mode
+ */
+ getEditorMode(): string | null;
+ /**
+ * Close a split
+ */
+ closeSplit(splitId: number): boolean;
+ /**
+ * Set the buffer displayed in a split
+ */
+ setSplitBuffer(splitId: number, bufferId: number): boolean;
+ /**
+ * Focus a specific split
+ */
+ focusSplit(splitId: number): boolean;
+ /**
+ * Set scroll position of a split
+ */
+ setSplitScroll(splitId: number, topByte: number): boolean;
+ /**
+ * Set the ratio of a split (0.0 to 1.0, 0.5 = equal)
+ */
+ setSplitRatio(splitId: number, ratio: number): boolean;
+ /**
+ * Distribute all splits evenly
+ */
+ distributeSplitsEvenly(): boolean;
+ /**
+ * Set cursor position in a buffer
+ */
+ setBufferCursor(bufferId: number, position: number): boolean;
+ /**
+ * Set a line indicator in the gutter
+ */
+ setLineIndicator(
+ bufferId: number,
+ line: number,
+ namespace: string,
+ symbol: string,
+ r: number,
+ g: number,
+ b: number,
+ priority: number,
+ ): boolean;
+ /**
+ * Clear line indicators in a namespace
+ */
+ clearLineIndicators(bufferId: number, namespace: string): boolean;
+ /**
+ * Enable or disable line numbers for a buffer
+ */
+ setLineNumbers(bufferId: number, enabled: boolean): boolean;
+ /**
+ * Create a scroll sync group for anchor-based synchronized scrolling
+ */
+ createScrollSyncGroup(
+ groupId: number,
+ leftSplit: number,
+ rightSplit: number,
+ ): boolean;
+ /**
+ * Set sync anchors for a scroll sync group
+ */
+ setScrollSyncAnchors(groupId: number, anchors: number[][]): boolean;
+ /**
+ * Remove a scroll sync group
+ */
+ removeScrollSyncGroup(groupId: number): boolean;
+ /**
+ * Execute multiple actions in sequence
+ *
+ * Takes typed ActionSpec array - serde validates field names at runtime
+ */
+ executeActions(actions: ActionSpec[]): boolean;
+ /**
+ * Show an action popup
+ *
+ * Takes a typed ActionPopupOptions struct - serde validates field names at runtime
+ */
+ showActionPopup(opts: ActionPopupOptions): boolean;
+ /**
+ * Disable LSP for a specific language
+ */
+ disableLspForLanguage(language: string): boolean;
+ /**
+ * Set the workspace root URI for a specific language's LSP server
+ * This allows plugins to specify project roots (e.g., directory containing .csproj)
+ */
+ setLspRootUri(language: string, uri: string): boolean;
+ /**
+ * Get all diagnostics from LSP
+ */
+ getAllDiagnostics(): JsDiagnostic[];
+ /**
+ * Get registered event handlers for an event
+ */
+ getHandlers(eventName: string): string[];
+ /**
+ * Create a virtual buffer in current split (async, returns buffer and split IDs)
+ */
+ createVirtualBuffer(
+ opts: CreateVirtualBufferOptions,
+ ): Promise;
+ /**
+ * Create a virtual buffer in a new split (async, returns buffer and split IDs)
+ */
+ createVirtualBufferInSplit(
+ opts: CreateVirtualBufferInSplitOptions,
+ ): Promise;
+ /**
+ * Create a virtual buffer in an existing split (async, returns buffer and split IDs)
+ */
+ createVirtualBufferInExistingSplit(
+ opts: CreateVirtualBufferInExistingSplitOptions,
+ ): Promise;
+ /**
+ * Set virtual buffer content (takes array of entry objects)
+ *
+ * Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support
+ */
+ setVirtualBufferContent(
+ bufferId: number,
+ entriesArr: Record[],
+ ): boolean;
+ /**
+ * Get text properties at cursor position (returns JS array)
+ */
+ getTextPropertiesAtCursor(bufferId: number): TextPropertiesAtCursor;
+ /**
+ * Spawn a process (async, returns request_id)
+ */
+ spawnProcess(
+ command: string,
+ args: string[],
+ cwd?: string,
+ ): ProcessHandle;
+ /**
+ * Wait for a process to complete and get its result (async)
+ */
+ spawnProcessWait(processId: number): Promise;
+ /**
+ * Get buffer text range (async, returns request_id)
+ */
+ getBufferText(bufferId: number, start: number, end: number): Promise;
+ /**
+ * Delay/sleep (async, returns request_id)
+ */
+ delay(durationMs: number): Promise;
+ /**
+ * Send LSP request (async, returns request_id)
+ */
+ sendLspRequest(
+ language: string,
+ method: string,
+ params: Record | null,
+ ): Promise;
+ /**
+ * Spawn a background process (async, returns request_id which is also process_id)
+ */
+ spawnBackgroundProcess(
+ command: string,
+ args: string[],
+ cwd?: string,
+ ): ProcessHandle;
+ /**
+ * Kill a background process
+ */
+ killBackgroundProcess(processId: number): boolean;
+ /**
+ * Force refresh of line display
+ */
+ refreshLines(bufferId: number): boolean;
+ /**
+ * Get the current locale
+ */
+ getCurrentLocale(): string;
+ /**
+ * Load a plugin from a file path (async)
+ */
+ loadPlugin(path: string): Promise;
+ /**
+ * Unload a plugin by name (async)
+ */
+ unloadPlugin(name: string): Promise;
+ /**
+ * Reload a plugin by name (async)
+ */
+ reloadPlugin(name: string): Promise;
+ /**
+ * List all loaded plugins (async)
+ * Returns array of { name: string, path: string, enabled: boolean }
+ */
+ listPlugins(): Promise<
+ Array<{
+ name: string;
+ path: string;
+ enabled: boolean;
+ }>
+ >;
}
diff --git a/crates/fresh-editor/plugins/lib/index.ts b/crates/fresh-editor/plugins/lib/index.ts
index 52fd19924..73087197d 100644
--- a/crates/fresh-editor/plugins/lib/index.ts
+++ b/crates/fresh-editor/plugins/lib/index.ts
@@ -1,24 +1,46 @@
/**
* Fresh Editor Plugin Library
*
- * Shared utilities for building LSP-related plugins with common patterns.
+ * Shared utilities for building plugins with common patterns:
+ * - Panel management and navigation
+ * - UI controls (buttons, lists, focus management)
+ * - Virtual buffer building with automatic styling
+ * - Finder/picker abstractions
*
* @example
* ```typescript
+ * // Panel and navigation utilities
* import { PanelManager, NavigationController, VirtualBufferFactory } from "./lib/index.ts";
* import type { Location, RGB, PanelOptions } from "./lib/index.ts";
+ *
+ * // UI Controls for building plugin interfaces
+ * import {
+ * ButtonControl, ListControl, FocusManager, FocusState,
+ * VirtualBufferBuilder
+ * } from "./lib/index.ts";
+ *
+ * // Build a UI with automatic style handling
+ * const builder = new VirtualBufferBuilder(bufferId, "my-plugin");
+ * builder
+ * .sectionHeader("My Plugin")
+ * .row(
+ * new ButtonControl("Action", FocusState.Focused).render(),
+ * { text: " ", styles: [] },
+ * new ButtonControl("Cancel").render()
+ * )
+ * .build();
* ```
*/
// Types
export type {
- RGB,
+ FileExplorerDecoration,
+ HighlightPattern,
Location,
+ NavigationOptions,
PanelOptions,
PanelState,
- NavigationOptions,
- HighlightPattern,
- FileExplorerDecoration,
+ RGB,
} from "./types.ts";
// Panel Management
@@ -29,18 +51,56 @@ export { NavigationController } from "./navigation-controller.ts";
// Buffer Creation
export { createVirtualBufferFactory } from "./virtual-buffer-factory.ts";
-export type { VirtualBufferOptions, SplitBufferOptions } from "./virtual-buffer-factory.ts";
+export type {
+ SplitBufferOptions,
+ VirtualBufferOptions,
+} from "./virtual-buffer-factory.ts";
// Finder Abstraction
-export { Finder, defaultFuzzyFilter, parseGrepLine, parseGrepOutput, getRelativePath, createLiveProvider } from "./finder.ts";
+export {
+ createLiveProvider,
+ defaultFuzzyFilter,
+ Finder,
+ getRelativePath,
+ parseGrepLine,
+ parseGrepOutput,
+} from "./finder.ts";
export type {
DisplayEntry,
- SearchSource,
FilterSource,
- PreviewConfig,
FinderConfig,
- PromptOptions,
- PanelOptions as FinderPanelOptions,
FinderProvider,
LivePanelOptions,
+ PanelOptions as FinderPanelOptions,
+ PreviewConfig,
+ PromptOptions,
+ SearchSource,
} from "./finder.ts";
+
+// UI Controls Library
+export {
+ ButtonControl,
+ FilterBar,
+ FocusManager,
+ FocusState,
+ GroupedListControl,
+ HelpBar,
+ Label,
+ ListControl,
+ Separator,
+ SplitView,
+ TextInputControl,
+ ToggleButton,
+} from "./controls.ts";
+export type {
+ ControlOutput,
+ FilterOption,
+ ItemRenderer,
+ KeyBinding,
+ ListGroup,
+ PanelLine,
+ StyleRange,
+} from "./controls.ts";
+
+// Virtual Buffer Builder
+export { createBuilder, VirtualBufferBuilder } from "./vbuffer.ts";
diff --git a/crates/fresh-editor/plugins/lib/navigation-controller.ts b/crates/fresh-editor/plugins/lib/navigation-controller.ts
index 6c0dbd952..242421dc8 100644
--- a/crates/fresh-editor/plugins/lib/navigation-controller.ts
+++ b/crates/fresh-editor/plugins/lib/navigation-controller.ts
@@ -33,7 +33,10 @@ export class NavigationController {
private currentIndex: number = 0;
private options: NavigationOptions;
- constructor(private readonly editor: EditorAPI, options: NavigationOptions = {}) {
+ constructor(
+ private readonly editor: EditorAPI,
+ options: NavigationOptions = {},
+ ) {
this.options = {
itemLabel: "Item",
wrap: false,
@@ -53,7 +56,10 @@ export class NavigationController {
this.currentIndex = 0;
} else {
// Clamp to valid range
- this.currentIndex = Math.min(this.currentIndex, Math.max(0, items.length - 1));
+ this.currentIndex = Math.min(
+ this.currentIndex,
+ Math.max(0, items.length - 1),
+ );
}
}
@@ -113,7 +119,10 @@ export class NavigationController {
if (this.options.wrap) {
this.currentIndex = (this.currentIndex + 1) % this.items.length;
} else {
- this.currentIndex = Math.min(this.currentIndex + 1, this.items.length - 1);
+ this.currentIndex = Math.min(
+ this.currentIndex + 1,
+ this.items.length - 1,
+ );
}
this.notifyChange();
}
@@ -125,7 +134,8 @@ export class NavigationController {
if (this.items.length === 0) return;
if (this.options.wrap) {
- this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length;
+ this.currentIndex = (this.currentIndex - 1 + this.items.length) %
+ this.items.length;
} else {
this.currentIndex = Math.max(this.currentIndex - 1, 0);
}
diff --git a/crates/fresh-editor/plugins/lib/panel-manager.ts b/crates/fresh-editor/plugins/lib/panel-manager.ts
index ab4dcdde4..cd40fbdc1 100644
--- a/crates/fresh-editor/plugins/lib/panel-manager.ts
+++ b/crates/fresh-editor/plugins/lib/panel-manager.ts
@@ -47,7 +47,7 @@ export class PanelManager {
constructor(
private readonly editor: EditorAPI,
private readonly panelName: string,
- private readonly modeName: string
+ private readonly modeName: string,
) {}
/**
@@ -94,7 +94,12 @@ export class PanelManager {
* @returns The buffer ID of the panel
*/
async open(options: PanelOptions): Promise {
- const { entries, ratio = 0.3, showLineNumbers = false, editingDisabled = true } = options;
+ const {
+ entries,
+ ratio = 0.3,
+ showLineNumbers = false,
+ editingDisabled = true,
+ } = options;
if (this.state.isOpen && this.state.bufferId !== null) {
// Panel already open - just update content
@@ -199,7 +204,11 @@ export class PanelManager {
* @param line - Line number to jump to (1-indexed)
* @param column - Column number to jump to (1-indexed)
*/
- async openInSource(filePath: string, line: number, column: number): Promise {
+ async openInSource(
+ filePath: string,
+ line: number,
+ column: number,
+ ): Promise {
if (this.state.sourceSplitId === null) {
return;
}
diff --git a/crates/fresh-editor/plugins/lib/search-utils.ts b/crates/fresh-editor/plugins/lib/search-utils.ts
index fe2f5cbdc..6f6f02afb 100644
--- a/crates/fresh-editor/plugins/lib/search-utils.ts
+++ b/crates/fresh-editor/plugins/lib/search-utils.ts
@@ -37,7 +37,12 @@ export interface DebouncedSearchOptions {
// Editor interface (subset of what we need)
interface EditorApi {
readFile(path: string): Promise;
- defineMode(name: string, parent: string, bindings: [string, string][], readOnly: boolean): void;
+ defineMode(
+ name: string,
+ parent: string,
+ bindings: [string, string][],
+ readOnly: boolean,
+ ): void;
createVirtualBufferInSplit(options: {
name: string;
mode: string;
@@ -131,7 +136,10 @@ export class SearchPreview {
if (this.bufferId === null) {
// Create preview mode if not exists
- this.editor.defineMode(this.modeName, "special", [["q", "close_buffer"]], true);
+ this.editor.defineMode(this.modeName, "special", [[
+ "q",
+ "close_buffer",
+ ]], true);
// Create preview in a split on the right
const result = await this.editor.createVirtualBufferInSplit({
@@ -217,7 +225,7 @@ export class DebouncedSearch {
async search(
query: string,
executor: () => ProcessHandle,
- onResults: (result: SpawnResult) => void
+ onResults: (result: SpawnResult) => void,
): Promise {
const thisVersion = ++this.searchVersion;
@@ -320,16 +328,15 @@ export function parseGrepLine(line: string): SearchMatch | null {
*/
export function matchesToSuggestions(
matches: SearchMatch[],
- maxResults: number = 100
+ maxResults: number = 100,
): PromptSuggestion[] {
const suggestions: PromptSuggestion[] = [];
for (let i = 0; i < Math.min(matches.length, maxResults); i++) {
const match = matches[i];
- const displayContent =
- match.content.length > 60
- ? match.content.substring(0, 57) + "..."
- : match.content;
+ const displayContent = match.content.length > 60
+ ? match.content.substring(0, 57) + "..."
+ : match.content;
suggestions.push({
text: `${match.file}:${match.line}`,
diff --git a/crates/fresh-editor/plugins/lib/vbuffer.ts b/crates/fresh-editor/plugins/lib/vbuffer.ts
new file mode 100644
index 000000000..74eb32d6f
--- /dev/null
+++ b/crates/fresh-editor/plugins/lib/vbuffer.ts
@@ -0,0 +1,398 @@
+///
+
+/**
+ * Virtual Buffer Builder for Fresh Editor Plugins
+ *
+ * Eliminates manual UTF-8 byte offset calculation when building plugin UIs.
+ * Uses character offsets internally and handles conversion automatically.
+ *
+ * @example
+ * ```typescript
+ * import { VirtualBufferBuilder } from "./lib/vbuffer.ts";
+ * import { ButtonControl, ListControl, FocusState } from "./lib/controls.ts";
+ *
+ * const builder = new VirtualBufferBuilder(bufferId, "my-plugin");
+ *
+ * builder
+ * .text(" Packages\n", [{ start: 0, end: 10, fg: "syntax.keyword" }])
+ * .newline()
+ * .row(
+ * new ButtonControl("Install", FocusState.Focused).render(),
+ * { text: " ", styles: [] },
+ * new ButtonControl("Update").render()
+ * )
+ * .newline()
+ * .separator(80)
+ * .control(packageList.render())
+ * .build();
+ * ```
+ */
+
+import type { ControlOutput, StyleRange } from "./controls.ts";
+import type { RGB } from "./types.ts";
+
+const editor = getEditor();
+
+/**
+ * Entry being accumulated in the builder
+ */
+interface BuilderEntry {
+ text: string;
+ styles: StyleRange[];
+}
+
+/**
+ * Builds virtual buffer content with automatic style offset tracking.
+ *
+ * Eliminates manual utf8ByteLength() calls and offset tracking.
+ * Styles use character offsets - byte conversion happens automatically in build().
+ */
+export class VirtualBufferBuilder {
+ private entries: BuilderEntry[] = [];
+
+ constructor(
+ /** Buffer ID to write to */
+ private bufferId: number,
+ /** Namespace for overlays (used in clearNamespace) */
+ private namespace: string = "ui",
+ ) {}
+
+ /**
+ * Add text with optional styles
+ *
+ * @param content - Text to add
+ * @param styles - Style ranges (character offsets relative to this text)
+ */
+ text(content: string, styles?: StyleRange[]): this {
+ this.entries.push({ text: content, styles: styles ?? [] });
+ return this;
+ }
+
+ /**
+ * Add a newline
+ */
+ newline(): this {
+ return this.text("\n");
+ }
+
+ /**
+ * Add multiple newlines
+ */
+ newlines(count: number): this {
+ return this.text("\n".repeat(count));
+ }
+
+ /**
+ * Add a blank line (newline followed by newline)
+ */
+ blankLine(): this {
+ return this.text("\n");
+ }
+
+ /**
+ * Add a horizontal separator
+ *
+ * @param width - Width in characters
+ * @param char - Character to use (default: "─")
+ * @param fg - Foreground color
+ */
+ separator(width: number, char: string = "─", fg?: string | RGB): this {
+ const line = char.repeat(width);
+ const styles: StyleRange[] = fg
+ ? [{ start: 0, end: line.length, fg }]
+ : [{ start: 0, end: line.length, fg: "ui.border" }];
+ return this.text(line + "\n", styles);
+ }
+
+ /**
+ * Add a control's rendered output
+ *
+ * @param output - Output from a control's render() method
+ */
+ control(output: ControlOutput): this {
+ this.entries.push(output);
+ return this;
+ }
+
+ /**
+ * Add a row of controls/text with automatic offset adjustment
+ *
+ * @param controls - Control outputs to combine horizontally
+ */
+ row(...controls: ControlOutput[]): this {
+ let combined = "";
+ const allStyles: StyleRange[] = [];
+ let offset = 0;
+
+ for (const ctrl of controls) {
+ // Shift styles by current offset
+ for (const style of ctrl.styles) {
+ allStyles.push({
+ ...style,
+ start: style.start + offset,
+ end: style.end + offset,
+ });
+ }
+ combined += ctrl.text;
+ offset += ctrl.text.length;
+ }
+
+ this.entries.push({ text: combined, styles: allStyles });
+ return this;
+ }
+
+ /**
+ * Add a labeled row (label + content)
+ *
+ * @param label - Label text
+ * @param content - Content control output
+ * @param labelFg - Label foreground color
+ */
+ labeledRow(
+ label: string,
+ content: ControlOutput,
+ labelFg?: string | RGB,
+ ): this {
+ const labelOutput: ControlOutput = {
+ text: label,
+ styles: labelFg ? [{ start: 0, end: label.length, fg: labelFg }] : [],
+ };
+ return this.row(labelOutput, content);
+ }
+
+ /**
+ * Add a section header
+ *
+ * @param title - Section title
+ * @param fg - Foreground color (default: syntax.keyword)
+ */
+ sectionHeader(title: string, fg: string | RGB = "syntax.keyword"): this {
+ return this.text(title + "\n", [{
+ start: 0,
+ end: title.length,
+ fg,
+ bold: true,
+ }]);
+ }
+
+ /**
+ * Add styled text with a single style applied to the entire text
+ *
+ * @param content - Text content
+ * @param fg - Foreground color
+ * @param bg - Background color
+ * @param bold - Bold text
+ */
+ styled(
+ content: string,
+ fg?: string | RGB,
+ bg?: string | RGB,
+ bold?: boolean,
+ ): this {
+ const styles: StyleRange[] = [];
+ if (fg || bg || bold) {
+ styles.push({ start: 0, end: content.length, fg, bg, bold });
+ }
+ return this.text(content, styles);
+ }
+
+ /**
+ * Add padded text (content padded to width)
+ *
+ * @param content - Text to pad
+ * @param width - Target width
+ * @param styles - Optional styles
+ */
+ padded(content: string, width: number, styles?: StyleRange[]): this {
+ const padded = content.length >= width
+ ? content.slice(0, width)
+ : content + " ".repeat(width - content.length);
+ return this.text(padded, styles);
+ }
+
+ /**
+ * Add a two-column row with fixed widths
+ *
+ * @param left - Left column content
+ * @param right - Right column content
+ * @param leftWidth - Width of left column
+ * @param divider - Divider between columns (default: " | ")
+ */
+ twoColumn(
+ left: ControlOutput,
+ right: ControlOutput,
+ leftWidth: number,
+ divider: string = " | ",
+ ): this {
+ // Pad/truncate left column
+ let leftText = left.text;
+ if (leftText.length > leftWidth) {
+ leftText = leftText.slice(0, leftWidth - 1) + "...";
+ } else {
+ leftText = leftText.padEnd(leftWidth);
+ }
+
+ const paddedLeft: ControlOutput = {
+ text: leftText,
+ styles: left.styles.map((s) => ({
+ ...s,
+ end: Math.min(s.end, leftText.length),
+ })),
+ };
+
+ const dividerOutput: ControlOutput = {
+ text: divider,
+ styles: [{ start: 0, end: divider.length, fg: "ui.border" }],
+ };
+
+ return this.row(paddedLeft, dividerOutput, right);
+ }
+
+ /**
+ * Conditionally add content
+ *
+ * @param condition - Whether to add the content
+ * @param fn - Function that adds content to the builder
+ */
+ when(condition: boolean, fn: (builder: this) => void): this {
+ if (condition) {
+ fn(this);
+ }
+ return this;
+ }
+
+ /**
+ * Add content for each item in an array
+ *
+ * @param items - Items to iterate
+ * @param fn - Function to add content for each item
+ */
+ forEach(
+ items: T[],
+ fn: (builder: this, item: T, index: number) => void,
+ ): this {
+ items.forEach((item, index) => fn(this, item, index));
+ return this;
+ }
+
+ /**
+ * Clear the builder to start fresh
+ */
+ clear(): this {
+ this.entries = [];
+ return this;
+ }
+
+ /**
+ * Build and apply to the virtual buffer
+ *
+ * This method:
+ * 1. Combines all text entries
+ * 2. Converts character offsets to byte offsets
+ * 3. Sets the buffer content
+ * 4. Clears old overlays
+ * 5. Applies new overlays
+ */
+ build(): void {
+ // Combine all text and adjust style offsets
+ let fullText = "";
+ const allStyles: StyleRange[] = [];
+ let charOffset = 0;
+
+ for (const entry of this.entries) {
+ for (const style of entry.styles) {
+ allStyles.push({
+ ...style,
+ start: style.start + charOffset,
+ end: style.end + charOffset,
+ });
+ }
+ fullText += entry.text;
+ charOffset += entry.text.length;
+ }
+
+ // Convert to TextPropertyEntry format
+ const textEntries: TextPropertyEntry[] = [{
+ text: fullText,
+ properties: {},
+ }];
+ editor.setVirtualBufferContent(this.bufferId, textEntries);
+
+ // Clear existing overlays and apply new ones
+ editor.clearNamespace(this.bufferId, this.namespace);
+
+ for (const style of allStyles) {
+ // Convert character offsets to byte offsets
+ const byteStart = this.charToByteOffset(fullText, style.start);
+ const byteEnd = this.charToByteOffset(fullText, style.end);
+
+ // Build overlay options
+ const options: Record = {};
+ if (style.fg !== undefined) options.fg = style.fg;
+ if (style.bg !== undefined) options.bg = style.bg;
+ if (style.bold) options.bold = true;
+ if (style.underline) options.underline = true;
+
+ if (Object.keys(options).length > 0) {
+ editor.addOverlay(
+ this.bufferId,
+ this.namespace,
+ byteStart,
+ byteEnd,
+ options,
+ );
+ }
+ }
+ }
+
+ /**
+ * Get the combined text without building (useful for debugging)
+ */
+ getText(): string {
+ return this.entries.map((e) => e.text).join("");
+ }
+
+ /**
+ * Get the combined styles without building (useful for debugging)
+ */
+ getStyles(): StyleRange[] {
+ const allStyles: StyleRange[] = [];
+ let charOffset = 0;
+
+ for (const entry of this.entries) {
+ for (const style of entry.styles) {
+ allStyles.push({
+ ...style,
+ start: style.start + charOffset,
+ end: style.end + charOffset,
+ });
+ }
+ charOffset += entry.text.length;
+ }
+
+ return allStyles;
+ }
+
+ /**
+ * Convert character offset to byte offset for UTF-8 text
+ */
+ private charToByteOffset(text: string, charOffset: number): number {
+ // Use TextEncoder for accurate UTF-8 byte counting
+ const encoder = new TextEncoder();
+ const prefix = text.slice(0, charOffset);
+ return encoder.encode(prefix).length;
+ }
+}
+
+/**
+ * Create a new VirtualBufferBuilder
+ *
+ * @param bufferId - Buffer ID to write to
+ * @param namespace - Namespace for overlays
+ */
+export function createBuilder(
+ bufferId: number,
+ namespace: string = "ui",
+): VirtualBufferBuilder {
+ return new VirtualBufferBuilder(bufferId, namespace);
+}
diff --git a/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts b/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts
index 9ebf341ad..07374b138 100644
--- a/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts
+++ b/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts
@@ -73,7 +73,10 @@ export function createVirtualBufferFactory(editor: EditorAPI) {
/**
* Create a virtual buffer in an existing split
*/
- async createInSplit(splitId: number, options: VirtualBufferOptions): Promise {
+ async createInSplit(
+ splitId: number,
+ options: VirtualBufferOptions,
+ ): Promise {
const {
name,
mode,
diff --git a/crates/fresh-editor/plugins/pkg.ts b/crates/fresh-editor/plugins/pkg.ts
index 4f3e99eb2..b14038c52 100644
--- a/crates/fresh-editor/plugins/pkg.ts
+++ b/crates/fresh-editor/plugins/pkg.ts
@@ -13,22 +13,28 @@
* - Version pinning with tags, branches, or commits
* - Lockfile for reproducibility
*
- * TODO: Plugin UI Component Library
- * ---------------------------------
- * The UI code in this plugin manually constructs buttons, lists, split views,
- * and focus management using raw text property entries. This is verbose and
- * error-prone. We need a shared UI component library that plugins can use to
- * build interfaces in virtual buffers:
- *
- * - Buttons, lists, scroll bars, tabs, split views, text inputs, etc.
- * - Automatic keyboard navigation and focus management
- * - Theme-aware styling
- *
- * The editor's settings UI already implements similar components - these could
- * be unified into a shared framework. See PLUGIN_MARKETPLACE_DESIGN.md for details.
+ * UI Implementation:
+ * Uses the shared controls library (lib/controls.ts, lib/vbuffer.ts) for
+ * building the package manager interface with automatic styling and
+ * UTF-8 byte offset handling.
*/
import { Finder } from "./lib/finder.ts";
+import {
+ ButtonControl,
+ FilterBar,
+ FocusState,
+ GroupedListControl,
+ HelpBar,
+ SplitView,
+ VirtualBufferBuilder,
+} from "./lib/index.ts";
+import type {
+ FilterOption,
+ KeyBinding,
+ ListGroup,
+ PanelLine,
+} from "./lib/index.ts";
const editor = getEditor();
@@ -39,7 +45,11 @@ const editor = getEditor();
const CONFIG_DIR = editor.getConfigDir();
const PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "plugins", "packages");
const THEMES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "themes", "packages");
-const LANGUAGES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "languages", "packages");
+const LANGUAGES_PACKAGES_DIR = editor.pathJoin(
+ CONFIG_DIR,
+ "languages",
+ "packages",
+);
const INDEX_DIR = editor.pathJoin(PACKAGES_DIR, ".index");
const CACHE_DIR = editor.pathJoin(PACKAGES_DIR, ".cache");
const LOCKFILE_PATH = editor.pathJoin(CONFIG_DIR, "fresh.lock");
@@ -193,14 +203,18 @@ function hashString(str: string): string {
* Run a git command without prompting for credentials.
* Uses git config options to prevent interactive prompts (cross-platform).
*/
-async function gitCommand(args: string[]): Promise<{ exit_code: number; stdout: string; stderr: string }> {
+async function gitCommand(
+ args: string[],
+): Promise<{ exit_code: number; stdout: string; stderr: string }> {
// Use git config options to disable credential prompts (works on Windows and Unix)
// -c credential.helper= disables credential helper
// -c core.askPass= disables askpass program
const gitArgs = [
- "-c", "credential.helper=",
- "-c", "core.askPass=",
- ...args
+ "-c",
+ "credential.helper=",
+ "-c",
+ "core.askPass=",
+ ...args,
];
const result = await editor.spawnProcess("git", gitArgs);
return result;
@@ -316,22 +330,36 @@ async function syncRegistry(): Promise {
if (editor.fileExists(indexPath)) {
// Update existing
editor.setStatus(`Updating registry: ${source}...`);
- const result = await gitCommand(["-C", `${indexPath}`, "pull", "--ff-only"]);
+ const result = await gitCommand([
+ "-C",
+ `${indexPath}`,
+ "pull",
+ "--ff-only",
+ ]);
if (result.exit_code === 0) {
synced++;
} else {
const errorMsg = result.stderr.includes("Could not resolve host")
? "Network error"
- : result.stderr.includes("Authentication") || result.stderr.includes("403")
+ : result.stderr.includes("Authentication") ||
+ result.stderr.includes("403")
? "Authentication failed (check if repo is public)"
: result.stderr.split("\n")[0] || "Unknown error";
errors.push(`${source}: ${errorMsg}`);
- editor.warn(`[pkg] Failed to update registry ${source}: ${result.stderr}`);
+ editor.warn(
+ `[pkg] Failed to update registry ${source}: ${result.stderr}`,
+ );
}
} else {
// Clone new
editor.setStatus(`Cloning registry: ${source}...`);
- const result = await gitCommand(["clone", "--depth", "1", `${source}`, `${indexPath}`]);
+ const result = await gitCommand([
+ "clone",
+ "--depth",
+ "1",
+ `${source}`,
+ `${indexPath}`,
+ ]);
if (result.exit_code === 0) {
synced++;
} else {
@@ -339,11 +367,14 @@ async function syncRegistry(): Promise {
? "Network error"
: result.stderr.includes("not found") || result.stderr.includes("404")
? "Repository not found"
- : result.stderr.includes("Authentication") || result.stderr.includes("403")
+ : result.stderr.includes("Authentication") ||
+ result.stderr.includes("403")
? "Authentication failed (check if repo is public)"
: result.stderr.split("\n")[0] || "Unknown error";
errors.push(`${source}: ${errorMsg}`);
- editor.warn(`[pkg] Failed to clone registry ${source}: ${result.stderr}`);
+ editor.warn(
+ `[pkg] Failed to clone registry ${source}: ${result.stderr}`,
+ );
}
}
}
@@ -354,7 +385,11 @@ async function syncRegistry(): Promise {
}
if (errors.length > 0) {
- editor.setStatus(`Registry: ${synced}/${sources.length} synced. Errors: ${errors.join("; ")}`);
+ editor.setStatus(
+ `Registry: ${synced}/${sources.length} synced. Errors: ${
+ errors.join("; ")
+ }`,
+ );
} else {
editor.setStatus(`Registry synced (${synced}/${sources.length} sources)`);
}
@@ -370,31 +405,44 @@ function loadRegistry(type: "plugins" | "themes" | "languages"): RegistryData {
const merged: RegistryData = {
schema_version: 1,
updated: new Date().toISOString(),
- packages: {}
+ packages: {},
};
for (const source of sources) {
// Try git index first
- const indexPath = editor.pathJoin(INDEX_DIR, hashString(source), `${type}.json`);
+ const indexPath = editor.pathJoin(
+ INDEX_DIR,
+ hashString(source),
+ `${type}.json`,
+ );
editor.debug(`[pkg] checking index path: ${indexPath}`);
let data = readJsonFile(indexPath);
// Fall back to cache if index not available
if (!data?.packages) {
- const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_${type}.json`);
+ const cachePath = editor.pathJoin(
+ CACHE_DIR,
+ `${hashString(source)}_${type}.json`,
+ );
data = readJsonFile(cachePath);
if (data?.packages) {
editor.debug(`[pkg] using cached data for ${type}`);
}
}
- editor.debug(`[pkg] data loaded: ${data ? 'yes' : 'no'}, packages: ${data?.packages ? Object.keys(data.packages).length : 0}`);
+ editor.debug(
+ `[pkg] data loaded: ${data ? "yes" : "no"}, packages: ${
+ data?.packages ? Object.keys(data.packages).length : 0
+ }`,
+ );
if (data?.packages) {
Object.assign(merged.packages, data.packages);
}
}
- editor.debug(`[pkg] total merged packages: ${Object.keys(merged.packages).length}`);
+ editor.debug(
+ `[pkg] total merged packages: ${Object.keys(merged.packages).length}`,
+ );
return merged;
}
@@ -409,7 +457,10 @@ async function cacheRegistry(): Promise {
const sourceHash = hashString(source);
for (const type of ["plugins", "themes", "languages"] as const) {
const indexPath = editor.pathJoin(INDEX_DIR, sourceHash, `${type}.json`);
- const cachePath = editor.pathJoin(CACHE_DIR, `${sourceHash}_${type}.json`);
+ const cachePath = editor.pathJoin(
+ CACHE_DIR,
+ `${sourceHash}_${type}.json`,
+ );
const data = readJsonFile(indexPath);
if (data?.packages && Object.keys(data.packages).length > 0) {
@@ -431,7 +482,10 @@ function isRegistrySynced(): boolean {
return true;
}
// Check cache
- const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_plugins.json`);
+ const cachePath = editor.pathJoin(
+ CACHE_DIR,
+ `${hashString(source)}_plugins.json`,
+ );
if (editor.fileExists(cachePath)) {
return true;
}
@@ -446,10 +500,14 @@ function isRegistrySynced(): boolean {
/**
* Get list of installed packages
*/
-function getInstalledPackages(type: "plugin" | "theme" | "language"): InstalledPackage[] {
- const packagesDir = type === "plugin" ? PACKAGES_DIR
- : type === "theme" ? THEMES_PACKAGES_DIR
- : LANGUAGES_PACKAGES_DIR;
+function getInstalledPackages(
+ type: "plugin" | "theme" | "language",
+): InstalledPackage[] {
+ const packagesDir = type === "plugin"
+ ? PACKAGES_DIR
+ : type === "theme"
+ ? THEMES_PACKAGES_DIR
+ : LANGUAGES_PACKAGES_DIR;
const packages: InstalledPackage[] = [];
if (!editor.fileExists(packagesDir)) {
@@ -483,7 +541,7 @@ function getInstalledPackages(type: "plugin" | "theme" | "language"): InstalledP
type,
source,
version: manifest?.version || "unknown",
- manifest
+ manifest,
});
}
}
@@ -512,14 +570,17 @@ interface ValidationResult {
* 2. package.json has required fields (name, type)
* 3. Entry file exists (for plugins)
*/
-function validatePackage(packageDir: string, packageName: string): ValidationResult {
+function validatePackage(
+ packageDir: string,
+ packageName: string,
+): ValidationResult {
const manifestPath = editor.pathJoin(packageDir, "package.json");
// Check package.json exists
if (!editor.fileExists(manifestPath)) {
return {
valid: false,
- error: `Missing package.json - expected at ${manifestPath}`
+ error: `Missing package.json - expected at ${manifestPath}`,
};
}
@@ -528,7 +589,7 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes
if (!manifest) {
return {
valid: false,
- error: "Invalid package.json - could not parse JSON"
+ error: "Invalid package.json - could not parse JSON",
};
}
@@ -536,21 +597,26 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes
if (!manifest.name) {
return {
valid: false,
- error: "Invalid package.json - missing 'name' field"
+ error: "Invalid package.json - missing 'name' field",
};
}
if (!manifest.type) {
return {
valid: false,
- error: "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', or 'language')"
+ error:
+ "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', or 'language')",
};
}
- if (manifest.type !== "plugin" && manifest.type !== "theme" && manifest.type !== "language") {
+ if (
+ manifest.type !== "plugin" && manifest.type !== "theme" &&
+ manifest.type !== "language"
+ ) {
return {
valid: false,
- error: `Invalid package.json - 'type' must be 'plugin', 'theme', or 'language', got '${manifest.type}'`
+ error:
+ `Invalid package.json - 'type' must be 'plugin', 'theme', or 'language', got '${manifest.type}'`,
};
}
@@ -568,7 +634,8 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes
return {
valid: false,
- error: `Missing entry file '${entryFile}' - check fresh.entry in package.json`
+ error:
+ `Missing entry file '${entryFile}' - check fresh.entry in package.json`,
};
}
@@ -577,20 +644,27 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes
// For language packs, validate at least one component is defined
if (manifest.type === "language") {
- if (!manifest.fresh?.grammar && !manifest.fresh?.language && !manifest.fresh?.lsp) {
+ if (
+ !manifest.fresh?.grammar && !manifest.fresh?.language &&
+ !manifest.fresh?.lsp
+ ) {
return {
valid: false,
- error: "Language package must define at least one of: grammar, language, or lsp"
+ error:
+ "Language package must define at least one of: grammar, language, or lsp",
};
}
// Validate grammar file exists if specified
if (manifest.fresh?.grammar?.file) {
- const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file);
+ const grammarPath = editor.pathJoin(
+ packageDir,
+ manifest.fresh.grammar.file,
+ );
if (!editor.fileExists(grammarPath)) {
return {
valid: false,
- error: `Grammar file not found: ${manifest.fresh.grammar.file}`
+ error: `Grammar file not found: ${manifest.fresh.grammar.file}`,
};
}
}
@@ -614,13 +688,15 @@ async function installPackage(
url: string,
name?: string,
type: "plugin" | "theme" | "language" = "plugin",
- version?: string
+ version?: string,
): Promise {
const parsed = parsePackageUrl(url);
const packageName = name || parsed.name;
- const packagesDir = type === "plugin" ? PACKAGES_DIR
- : type === "theme" ? THEMES_PACKAGES_DIR
- : LANGUAGES_PACKAGES_DIR;
+ const packagesDir = type === "plugin"
+ ? PACKAGES_DIR
+ : type === "theme"
+ ? THEMES_PACKAGES_DIR
+ : LANGUAGES_PACKAGES_DIR;
const targetDir = editor.pathJoin(packagesDir, packageName);
if (editor.fileExists(targetDir)) {
@@ -637,7 +713,12 @@ async function installPackage(
return await installFromMonorepo(parsed, packageName, targetDir, version);
} else {
// Standard installation: clone directly
- return await installFromRepo(parsed.repoUrl, packageName, targetDir, version);
+ return await installFromRepo(
+ parsed.repoUrl,
+ packageName,
+ targetDir,
+ version,
+ );
}
}
@@ -648,7 +729,7 @@ async function installFromRepo(
repoUrl: string,
packageName: string,
targetDir: string,
- version?: string
+ version?: string,
): Promise {
// Clone the repository
const cloneArgs = ["clone"];
@@ -660,11 +741,13 @@ async function installFromRepo(
const result = await gitCommand(cloneArgs);
if (result.exit_code !== 0) {
- const errorMsg = result.stderr.includes("not found") || result.stderr.includes("404")
- ? "Repository not found"
- : result.stderr.includes("Authentication") || result.stderr.includes("403")
- ? "Access denied (repository may be private)"
- : result.stderr.split("\n")[0] || "Clone failed";
+ const errorMsg =
+ result.stderr.includes("not found") || result.stderr.includes("404")
+ ? "Repository not found"
+ : result.stderr.includes("Authentication") ||
+ result.stderr.includes("403")
+ ? "Access denied (repository may be private)"
+ : result.stderr.split("\n")[0] || "Clone failed";
editor.setStatus(`Failed to install ${packageName}: ${errorMsg}`);
return false;
}
@@ -673,7 +756,9 @@ async function installFromRepo(
if (version && version !== "latest") {
const checkoutResult = await checkoutVersion(targetDir, version);
if (!checkoutResult) {
- editor.setStatus(`Installed ${packageName} but failed to checkout version ${version}`);
+ editor.setStatus(
+ `Installed ${packageName} but failed to checkout version ${version}`,
+ );
}
}
@@ -692,15 +777,29 @@ async function installFromRepo(
// Dynamically load plugins, reload themes, or load language packs
if (manifest?.type === "plugin" && validation.entryPath) {
await editor.loadPlugin(validation.entryPath);
- editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed and activated ${packageName}${
+ manifest ? ` v${manifest.version}` : ""
+ }`,
+ );
} else if (manifest?.type === "theme") {
editor.reloadThemes();
- editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed theme ${packageName}${
+ manifest ? ` v${manifest.version}` : ""
+ }`,
+ );
} else if (manifest?.type === "language") {
await loadLanguagePack(targetDir, manifest);
- editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed language pack ${packageName}${
+ manifest ? ` v${manifest.version}` : ""
+ }`,
+ );
} else {
- editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`,
+ );
}
return true;
}
@@ -718,7 +817,7 @@ async function installFromMonorepo(
parsed: ParsedPackageUrl,
packageName: string,
targetDir: string,
- version?: string
+ version?: string,
): Promise {
const tempDir = `/tmp/fresh-pkg-${hashString(parsed.repoUrl)}-${Date.now()}`;
@@ -733,9 +832,11 @@ async function installFromMonorepo(
const cloneResult = await gitCommand(cloneArgs);
if (cloneResult.exit_code !== 0) {
- const errorMsg = cloneResult.stderr.includes("not found") || cloneResult.stderr.includes("404")
+ const errorMsg = cloneResult.stderr.includes("not found") ||
+ cloneResult.stderr.includes("404")
? "Repository not found"
- : cloneResult.stderr.includes("Authentication") || cloneResult.stderr.includes("403")
+ : cloneResult.stderr.includes("Authentication") ||
+ cloneResult.stderr.includes("403")
? "Access denied (repository may be private)"
: cloneResult.stderr.split("\n")[0] || "Clone failed";
editor.setStatus(`Failed to clone repository: ${errorMsg}`);
@@ -757,7 +858,11 @@ async function installFromMonorepo(
// Copy subdirectory to target
editor.setStatus(`Installing ${packageName} from ${parsed.subpath}...`);
- const copyResult = await editor.spawnProcess("cp", ["-r", subpathDir, targetDir]);
+ const copyResult = await editor.spawnProcess("cp", [
+ "-r",
+ subpathDir,
+ targetDir,
+ ]);
if (copyResult.exit_code !== 0) {
editor.setStatus(`Failed to copy package: ${copyResult.stderr}`);
await editor.spawnProcess("rm", ["-rf", tempDir]);
@@ -767,7 +872,9 @@ async function installFromMonorepo(
// Validate package structure
const validation = validatePackage(targetDir, packageName);
if (!validation.valid) {
- editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`);
+ editor.warn(
+ `[pkg] Invalid package '${packageName}': ${validation.error}`,
+ );
editor.setStatus(`Failed to install ${packageName}: ${validation.error}`);
// Clean up the invalid package
await editor.spawnProcess("rm", ["-rf", targetDir]);
@@ -780,24 +887,41 @@ async function installFromMonorepo(
repository: parsed.repoUrl,
subpath: parsed.subpath,
installed_from: `${parsed.repoUrl}#${parsed.subpath}`,
- installed_at: new Date().toISOString()
+ installed_at: new Date().toISOString(),
};
- await writeJsonFile(editor.pathJoin(targetDir, ".fresh-source.json"), sourceInfo);
+ await writeJsonFile(
+ editor.pathJoin(targetDir, ".fresh-source.json"),
+ sourceInfo,
+ );
const manifest = validation.manifest;
// Dynamically load plugins, reload themes, or load language packs
if (manifest?.type === "plugin" && validation.entryPath) {
await editor.loadPlugin(validation.entryPath);
- editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed and activated ${packageName}${
+ manifest ? ` v${manifest.version}` : ""
+ }`,
+ );
} else if (manifest?.type === "theme") {
editor.reloadThemes();
- editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed theme ${packageName}${
+ manifest ? ` v${manifest.version}` : ""
+ }`,
+ );
} else if (manifest?.type === "language") {
await loadLanguagePack(targetDir, manifest);
- editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed language pack ${packageName}${
+ manifest ? ` v${manifest.version}` : ""
+ }`,
+ );
} else {
- editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`);
+ editor.setStatus(
+ `Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`,
+ );
}
return true;
} finally {
@@ -809,12 +933,18 @@ async function installFromMonorepo(
/**
* Load a language pack (register grammar, language config, and LSP server)
*/
-async function loadLanguagePack(packageDir: string, manifest: PackageManifest): Promise {
+async function loadLanguagePack(
+ packageDir: string,
+ manifest: PackageManifest,
+): Promise {
const langId = manifest.name;
// Register grammar if present
if (manifest.fresh?.grammar) {
- const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file);
+ const grammarPath = editor.pathJoin(
+ packageDir,
+ manifest.fresh.grammar.file,
+ );
const extensions = manifest.fresh.grammar.extensions || [];
editor.registerGrammar(langId, grammarPath, extensions);
}
@@ -830,10 +960,12 @@ async function loadLanguagePack(packageDir: string, manifest: PackageManifest):
tabSize: lang.tabSize ?? null,
autoIndent: lang.autoIndent ?? null,
showWhitespaceTabs: lang.showWhitespaceTabs ?? null,
- formatter: lang.formatter ? {
- command: lang.formatter.command,
- args: lang.formatter.args ?? [],
- } : null,
+ formatter: lang.formatter
+ ? {
+ command: lang.formatter.command,
+ args: lang.formatter.args ?? [],
+ }
+ : null,
});
}
@@ -855,13 +987,21 @@ async function loadLanguagePack(packageDir: string, manifest: PackageManifest):
/**
* Checkout a specific version in a package directory
*/
-async function checkoutVersion(pkgPath: string, version: string): Promise {
+async function checkoutVersion(
+ pkgPath: string,
+ version: string,
+): Promise {
let target: string;
if (version === "latest") {
// Get latest tag
- const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]);
- const tags = tagsResult.stdout.split("\n").filter(t => t.trim());
+ const tagsResult = await gitCommand([
+ "-C",
+ `${pkgPath}`,
+ "tag",
+ "--sort=-v:refname",
+ ]);
+ const tags = tagsResult.stdout.split("\n").filter((t) => t.trim());
target = tags[0] || "HEAD";
} else if (version.startsWith("^") || version.startsWith("~")) {
// Semver matching - find best matching tag
@@ -885,18 +1025,26 @@ async function checkoutVersion(pkgPath: string, version: string): Promise {
- const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]);
- const tags = tagsResult.stdout.split("\n").filter(t => t.trim());
+async function findMatchingSemver(
+ pkgPath: string,
+ spec: string,
+): Promise {
+ const tagsResult = await gitCommand([
+ "-C",
+ `${pkgPath}`,
+ "tag",
+ "--sort=-v:refname",
+ ]);
+ const tags = tagsResult.stdout.split("\n").filter((t) => t.trim());
// Simple semver matching (^ means compatible, ~ means patch only)
const prefix = spec.startsWith("^") ? "^" : "~";
const baseVersion = spec.slice(1);
- const [major, minor] = baseVersion.split(".").map(n => parseInt(n, 10));
+ const [major, minor] = baseVersion.split(".").map((n) => parseInt(n, 10));
for (const tag of tags) {
const version = tag.replace(/^v/, "");
- const [tagMajor, tagMinor] = version.split(".").map(n => parseInt(n, 10));
+ const [tagMajor, tagMinor] = version.split(".").map((n) => parseInt(n, 10));
if (prefix === "^") {
// Compatible: same major
@@ -931,7 +1079,9 @@ async function updatePackage(pkg: InstalledPackage): Promise {
// Use listPlugins to find the correct runtime plugin name
if (pkg.type === "plugin") {
const loadedPlugins = await editor.listPlugins();
- const plugin = loadedPlugins.find((p: { path: string }) => p.path.startsWith(pkg.path));
+ const plugin = loadedPlugins.find((p: { path: string }) =>
+ p.path.startsWith(pkg.path)
+ );
if (plugin) {
await editor.reloadPlugin(plugin.name);
}
@@ -944,7 +1094,8 @@ async function updatePackage(pkg: InstalledPackage): Promise {
} else {
const errorMsg = result.stderr.includes("Could not resolve host")
? "Network error"
- : result.stderr.includes("Authentication") || result.stderr.includes("403")
+ : result.stderr.includes("Authentication") ||
+ result.stderr.includes("403")
? "Authentication failed"
: result.stderr.split("\n")[0] || "Update failed";
editor.setStatus(`Failed to update ${pkg.name}: ${errorMsg}`);
@@ -962,7 +1113,9 @@ async function removePackage(pkg: InstalledPackage): Promise {
// Use listPlugins to find the correct runtime plugin name by matching path
if (pkg.type === "plugin") {
const loadedPlugins = await editor.listPlugins();
- const plugin = loadedPlugins.find((p: { path: string }) => p.path.startsWith(pkg.path));
+ const plugin = loadedPlugins.find((p: { path: string }) =>
+ p.path.startsWith(pkg.path)
+ );
if (plugin) {
await editor.unloadPlugin(plugin.name).catch(() => {});
}
@@ -1004,7 +1157,9 @@ async function updateAllPackages(): Promise {
let failed = 0;
for (const pkg of all) {
- editor.setStatus(`Updating ${pkg.name} (${updated + failed + 1}/${all.length})...`);
+ editor.setStatus(
+ `Updating ${pkg.name} (${updated + failed + 1}/${all.length})...`,
+ );
const result = await gitCommand(["-C", `${pkg.path}`, "pull", "--ff-only"]);
if (result.exit_code === 0) {
@@ -1016,7 +1171,11 @@ async function updateAllPackages(): Promise {
}
}
- editor.setStatus(`Update complete: ${updated} updated, ${all.length - updated - failed} unchanged, ${failed} failed`);
+ editor.setStatus(
+ `Update complete: ${updated} updated, ${
+ all.length - updated - failed
+ } unchanged, ${failed} failed`,
+ );
}
// =============================================================================
@@ -1036,18 +1195,23 @@ async function generateLockfile(): Promise {
const lockfile: Lockfile = {
lockfile_version: 1,
generated: new Date().toISOString(),
- packages: {}
+ packages: {},
};
for (const pkg of all) {
// Get current commit
- const commitResult = await gitCommand(["-C", `${pkg.path}`, "rev-parse", "HEAD"]);
+ const commitResult = await gitCommand([
+ "-C",
+ `${pkg.path}`,
+ "rev-parse",
+ "HEAD",
+ ]);
const commit = commitResult.stdout.trim();
lockfile.packages[pkg.name] = {
source: pkg.source,
commit,
- version: pkg.version
+ version: pkg.version,
};
}
@@ -1074,7 +1238,11 @@ async function installFromLockfile(): Promise {
let failed = 0;
for (const [name, entry] of Object.entries(lockfile.packages)) {
- editor.setStatus(`Installing ${name} (${installed + failed + 1}/${Object.keys(lockfile.packages).length})...`);
+ editor.setStatus(
+ `Installing ${name} (${installed + failed + 1}/${
+ Object.keys(lockfile.packages).length
+ })...`,
+ );
// Check if already installed
const pluginPath = editor.pathJoin(PACKAGES_DIR, name);
@@ -1084,7 +1252,12 @@ async function installFromLockfile(): Promise {
// Already installed, just checkout the commit
const path = editor.fileExists(pluginPath) ? pluginPath : themePath;
await gitCommand(["-C", `${path}`, "fetch"]);
- const result = await gitCommand(["-C", `${path}`, "checkout", entry.commit]);
+ const result = await gitCommand([
+ "-C",
+ `${path}`,
+ "checkout",
+ entry.commit,
+ ]);
if (result.exit_code === 0) {
installed++;
} else {
@@ -1093,7 +1266,11 @@ async function installFromLockfile(): Promise {
} else {
// Need to clone
await ensureDir(PACKAGES_DIR);
- const result = await gitCommand(["clone", `${entry.source}`, `${pluginPath}`]);
+ const result = await gitCommand([
+ "clone",
+ `${entry.source}`,
+ `${pluginPath}`,
+ ]);
if (result.exit_code === 0) {
await gitCommand(["-C", `${pluginPath}`, "checkout", entry.commit]);
@@ -1104,7 +1281,9 @@ async function installFromLockfile(): Promise {
}
}
- editor.setStatus(`Lockfile install complete: ${installed} installed, ${failed} failed`);
+ editor.setStatus(
+ `Lockfile install complete: ${installed} installed, ${failed} failed`,
+ );
}
// =============================================================================
@@ -1135,11 +1314,11 @@ interface PackageListItem {
// Focus target types for Tab navigation
type FocusTarget =
- | { type: "filter"; index: number } // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages
+ | { type: "filter"; index: number } // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages
| { type: "sync" }
| { type: "search" }
- | { type: "list" } // Package list (use arrows to navigate)
- | { type: "action"; index: number }; // Action buttons for selected package
+ | { type: "list" } // Package list (use arrows to navigate)
+ | { type: "action"; index: number }; // Action buttons for selected package
interface PkgManagerState {
isOpen: boolean;
@@ -1150,7 +1329,7 @@ interface PkgManagerState {
searchQuery: string;
items: PackageListItem[];
selectedIndex: number;
- focus: FocusTarget; // What element has Tab focus
+ focus: FocusTarget; // What element has Tab focus
isLoading: boolean;
}
@@ -1184,7 +1363,7 @@ const pkgTheme: Record = {
available: { fg: { theme: "editor.fg", rgb: [200, 200, 210] } },
selected: {
fg: { theme: "ui.menu_active_fg", rgb: [255, 255, 255] },
- bg: { theme: "ui.menu_active_bg", rgb: [50, 80, 120] }
+ bg: { theme: "ui.menu_active_bg", rgb: [50, 80, 120] },
},
// Descriptions and details
@@ -1202,14 +1381,14 @@ const pkgTheme: Record = {
// Filter buttons
filterActive: {
fg: { rgb: [255, 255, 255] },
- bg: { theme: "syntax.keyword", rgb: [60, 100, 160] }
+ bg: { theme: "syntax.keyword", rgb: [60, 100, 160] },
},
filterInactive: {
fg: { rgb: [160, 160, 170] },
},
filterFocused: {
fg: { rgb: [255, 255, 255] },
- bg: { rgb: [80, 80, 90] }
+ bg: { rgb: [80, 80, 90] },
},
// Action buttons
@@ -1218,17 +1397,17 @@ const pkgTheme: Record = {
},
buttonFocused: {
fg: { rgb: [255, 255, 255] },
- bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }
+ bg: { theme: "syntax.keyword", rgb: [60, 110, 180] },
},
// Search box - distinct input field appearance
searchBox: {
fg: { rgb: [200, 200, 210] },
- bg: { rgb: [40, 42, 48] }
+ bg: { rgb: [40, 42, 48] },
},
searchBoxFocused: {
fg: { rgb: [255, 255, 255] },
- bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }
+ bg: { theme: "syntax.keyword", rgb: [60, 110, 180] },
},
// Status indicators
@@ -1236,6 +1415,33 @@ const pkgTheme: Record = {
statusUpdate: { fg: { rgb: [220, 180, 80] } },
};
+/** Extract theme colors with fallback to RGB - simplifies theme access */
+function themeColor(
+ style: ThemeColor,
+): {
+ fg?: string | [number, number, number];
+ bg?: string | [number, number, number];
+} {
+ return {
+ fg: style.fg?.theme ?? style.fg?.rgb,
+ bg: style.bg?.theme ?? style.bg?.rgb,
+ };
+}
+
+/** Get fg color from theme style */
+function themeFg(
+ style: ThemeColor,
+): string | [number, number, number] | undefined {
+ return style.fg?.theme ?? style.fg?.rgb;
+}
+
+/** Get bg color from theme style */
+function themeBg(
+ style: ThemeColor,
+): string | [number, number, number] | undefined {
+ return style.bg?.theme ?? style.bg?.rgb;
+}
+
// Define pkg-manager mode with arrow key navigation
editor.defineMode(
"pkg-manager",
@@ -1249,7 +1455,7 @@ editor.defineMode(
["Escape", "pkg_back_or_close"],
["/", "pkg_search"],
],
- true // read-only
+ true, // read-only
);
// Define pkg-detail mode for package details view
@@ -1264,7 +1470,7 @@ editor.defineMode(
["S-Tab", "pkg_prev_button"],
["Escape", "pkg_back_or_close"],
],
- true // read-only
+ true, // read-only
);
/**
@@ -1279,7 +1485,13 @@ function buildPackageList(): PackageListItem[] {
const installedLanguages = getInstalledPackages("language");
const installedMap = new Map();
- for (const pkg of [...installedPlugins, ...installedThemes, ...installedLanguages]) {
+ for (
+ const pkg of [
+ ...installedPlugins,
+ ...installedThemes,
+ ...installedLanguages,
+ ]
+ ) {
installedMap.set(pkg.name, pkg);
items.push({
type: "installed",
@@ -1382,26 +1594,26 @@ function getFilteredItems(): PackageListItem[] {
// Apply filter
switch (pkgState.filter) {
case "installed":
- items = items.filter(i => i.installed);
+ items = items.filter((i) => i.installed);
break;
case "plugins":
- items = items.filter(i => i.packageType === "plugin");
+ items = items.filter((i) => i.packageType === "plugin");
break;
case "themes":
- items = items.filter(i => i.packageType === "theme");
+ items = items.filter((i) => i.packageType === "theme");
break;
case "languages":
- items = items.filter(i => i.packageType === "language");
+ items = items.filter((i) => i.packageType === "language");
break;
}
// Apply search (case insensitive)
if (pkgState.searchQuery) {
const query = pkgState.searchQuery.toLowerCase();
- items = items.filter(i =>
+ items = items.filter((i) =>
i.name.toLowerCase().includes(query) ||
(i.description && i.description.toLowerCase().includes(query)) ||
- (i.keywords && i.keywords.some(k => k.toLowerCase().includes(query)))
+ (i.keywords && i.keywords.some((k) => k.toLowerCase().includes(query)))
);
}
@@ -1427,7 +1639,7 @@ function formatNumber(n: number | undefined): string {
}
// Layout constants
-const LIST_WIDTH = 36; // Width of left panel (package list)
+const LIST_WIDTH = 36; // Width of left panel (package list)
const TOTAL_WIDTH = 88; // Total width of UI
const DETAIL_WIDTH = TOTAL_WIDTH - LIST_WIDTH - 3; // Right panel width (minus divider)
@@ -1470,7 +1682,9 @@ function wrapText(text: string, maxWidth: number): string[] {
currentLine += (currentLine ? " " : "") + word;
} else {
if (currentLine) lines.push(currentLine);
- currentLine = word.length > maxWidth ? word.slice(0, maxWidth - 1) + "…" : word;
+ currentLine = word.length > maxWidth
+ ? word.slice(0, maxWidth - 1) + "…"
+ : word;
}
}
if (currentLine) lines.push(currentLine);
@@ -1478,440 +1692,298 @@ function wrapText(text: string, maxWidth: number): string[] {
}
/**
- * Build virtual buffer entries for the package manager (split-view layout)
+ * Render the package manager UI using VirtualBufferBuilder
+ *
+ * This replaces the old buildListViewEntries() and applyPkgManagerHighlighting()
+ * functions with a cleaner implementation using the controls library.
*/
-function buildListViewEntries(): TextPropertyEntry[] {
- const entries: TextPropertyEntry[] = [];
+function renderPkgManagerUI(): void {
+ if (pkgState.bufferId === null) return;
+
+ const builder = new VirtualBufferBuilder(pkgState.bufferId, "pkg");
const items = getFilteredItems();
const selectedItem = items.length > 0 && pkgState.selectedIndex < items.length
- ? items[pkgState.selectedIndex] : null;
- const installedItems = items.filter(i => i.installed);
- const availableItems = items.filter(i => !i.installed);
+ ? items[pkgState.selectedIndex]
+ : null;
+ const installedItems = items.filter((i) => i.installed);
+ const availableItems = items.filter((i) => !i.installed);
// === HEADER ===
- entries.push({
- text: " Packages\n",
- properties: { type: "header" },
- });
-
- // Empty line after header
- entries.push({ text: "\n", properties: { type: "blank" } });
+ builder.styled(" Packages\n", themeFg(pkgTheme.header));
+ builder.newline();
- // === SEARCH BAR (input-style) ===
+ // === SEARCH BAR ===
const searchFocused = isButtonFocused("search");
- const searchInputWidth = 30;
const searchText = pkgState.searchQuery || "";
- const searchDisplay = searchText.length > searchInputWidth - 1
- ? searchText.slice(0, searchInputWidth - 2) + "…"
- : searchText.padEnd(searchInputWidth);
-
- entries.push({ text: " Search: ", properties: { type: "search-label" } });
- entries.push({
- text: searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `,
- properties: { type: "search-input", focused: searchFocused },
- });
- entries.push({ text: "\n", properties: { type: "newline" } });
-
- // === FILTER BAR with focusable buttons ===
- const filters: Array<{ id: string; label: string }> = [
+ const searchDisplay = searchText.length > 29
+ ? searchText.slice(0, 27) + "…"
+ : searchText.padEnd(30);
+ const searchStyle = themeColor(
+ searchFocused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox,
+ );
+
+ builder.styled(" Search: ", themeFg(pkgTheme.infoLabel));
+ builder.styled(
+ searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `,
+ searchStyle.fg,
+ searchStyle.bg,
+ );
+ builder.newline();
+
+ // === FILTER BAR ===
+ const filterOptions: FilterOption[] = [
{ id: "all", label: "All" },
{ id: "installed", label: "Installed" },
{ id: "plugins", label: "Plugins" },
{ id: "themes", label: "Themes" },
{ id: "languages", label: "Languages" },
];
-
- // Build filter buttons with position tracking
- let filterBarParts: Array<{ text: string; type: string; focused?: boolean; active?: boolean }> = [];
- filterBarParts.push({ text: " ", type: "spacer" });
-
- for (let i = 0; i < filters.length; i++) {
- const f = filters[i];
- const isActive = pkgState.filter === f.id;
- const isFocused = isButtonFocused("filter", i);
- // Always reserve space for brackets - show [ ] when focused, spaces when not
- const leftBracket = isFocused ? "[" : " ";
- const rightBracket = isFocused ? "]" : " ";
- filterBarParts.push({
- text: `${leftBracket} ${f.label} ${rightBracket}`,
- type: "filter-btn",
- focused: isFocused,
- active: isActive,
- });
- }
-
- filterBarParts.push({ text: " ", type: "spacer" });
-
- // Sync button - always reserve space for brackets
+ const focusedFilterIdx = pkgState.focus.type === "filter"
+ ? pkgState.focus.index
+ : -1;
+ const filterBar = new FilterBar(
+ filterOptions,
+ pkgState.filter,
+ focusedFilterIdx,
+ {
+ activeFg: themeFg(pkgTheme.filterActive),
+ activeBg: themeBg(pkgTheme.filterActive),
+ inactiveFg: themeFg(pkgTheme.filterInactive),
+ focusedFg: themeFg(pkgTheme.filterFocused),
+ focusedBg: themeBg(pkgTheme.filterFocused),
+ },
+ );
+ builder.text(" ").control(filterBar.render()).text(" ");
+
+ // Sync button
const syncFocused = isButtonFocused("sync");
- const syncLeft = syncFocused ? "[" : " ";
- const syncRight = syncFocused ? "]" : " ";
- filterBarParts.push({ text: `${syncLeft} Sync ${syncRight}`, type: "sync-btn", focused: syncFocused });
-
- // Emit each filter bar part as separate entry for individual styling
- for (const part of filterBarParts) {
- entries.push({
- text: part.text,
- properties: {
- type: part.type,
- focused: part.focused,
- active: part.active,
- },
- });
- }
- entries.push({ text: "\n", properties: { type: "newline" } });
+ const syncStyle = themeColor(
+ syncFocused ? pkgTheme.buttonFocused : pkgTheme.button,
+ );
+ builder.styled(
+ new ButtonControl(
+ "Sync",
+ syncFocused ? FocusState.Focused : FocusState.Normal,
+ ).render().text,
+ syncStyle.fg,
+ syncStyle.bg,
+ );
+ builder.newline();
// === TOP SEPARATOR ===
- entries.push({
- text: " " + "─".repeat(TOTAL_WIDTH - 2) + "\n",
- properties: { type: "separator" },
- });
+ builder.styled(
+ " " + "─".repeat(TOTAL_WIDTH - 2) + "\n",
+ themeFg(pkgTheme.separator),
+ );
// === SPLIT VIEW: Package list on left, Details on right ===
+ const leftLines = buildLeftPanel(installedItems, availableItems, items);
+ const rightLines = buildRightPanel(selectedItem);
+
+ const splitView = new SplitView(leftLines, rightLines, {
+ leftWidth: LIST_WIDTH,
+ divider: "│",
+ dividerFg: themeFg(pkgTheme.divider),
+ minRows: 8,
+ leftPadding: " ",
+ rightPadding: " ",
+ });
+ builder.control(splitView.render());
+
+ // === BOTTOM SEPARATOR ===
+ builder.styled(
+ " " + "─".repeat(TOTAL_WIDTH - 2) + "\n",
+ themeFg(pkgTheme.separator),
+ );
- // Build left panel lines (package list)
- const leftLines: Array<{ text: string; type: string; selected?: boolean; installed?: boolean }> = [];
+ // === HELP LINE ===
+ const actionLabel = {
+ action: "Activate",
+ filter: "Filter",
+ sync: "Sync",
+ search: "Search",
+ list: "Select",
+ }[pkgState.focus.type] || "Select";
+ const helpBindings: KeyBinding[] = [
+ { key: "↑↓", action: "Navigate" },
+ { key: "Tab", action: "Next" },
+ { key: "/", action: "Search" },
+ { key: "Enter", action: actionLabel },
+ { key: "Esc", action: "Close" },
+ ];
+ const helpBar = new HelpBar(helpBindings, { fg: themeFg(pkgTheme.help) });
+ builder.control(helpBar.render()).newline();
- // Installed section
- if (installedItems.length > 0) {
- leftLines.push({ text: `INSTALLED (${installedItems.length})`, type: "section-title" });
-
- let idx = 0;
- for (const item of installedItems) {
- const isSelected = idx === pkgState.selectedIndex;
- const listFocused = pkgState.focus.type === "list";
- const prefix = isSelected && listFocused ? "▸" : " ";
- const status = item.updateAvailable ? "↑" : "✓";
- const ver = item.version.length > 7 ? item.version.slice(0, 6) + "…" : item.version;
- const name = item.name.length > 18 ? item.name.slice(0, 17) + "…" : item.name;
- const line = `${prefix} ${name.padEnd(18)} ${ver.padEnd(7)} ${status}`;
- leftLines.push({ text: line, type: "package-row", selected: isSelected, installed: true });
- idx++;
- }
- }
+ // Build and apply to buffer
+ builder.build();
+}
- // Available section
- if (availableItems.length > 0) {
- if (leftLines.length > 0) leftLines.push({ text: "", type: "blank" });
- leftLines.push({ text: `AVAILABLE (${availableItems.length})`, type: "section-title" });
-
- let idx = installedItems.length;
- for (const item of availableItems) {
- const isSelected = idx === pkgState.selectedIndex;
- const listFocused = pkgState.focus.type === "list";
- const prefix = isSelected && listFocused ? "▸" : " ";
- const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : "P";
- const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name;
- const line = `${prefix} ${name.padEnd(22)} [${typeTag}]`;
- leftLines.push({ text: line, type: "package-row", selected: isSelected, installed: false });
- idx++;
- }
+/** Format a package item for the list - handles both installed and available */
+function formatPackageItem(
+ item: PackageListItem,
+ _selected: boolean,
+ _index: number,
+): string {
+ if (item.installed) {
+ const status = item.updateAvailable ? "↑" : "✓";
+ const ver = item.version.length > 7
+ ? item.version.slice(0, 6) + "…"
+ : item.version;
+ const name = item.name.length > 18
+ ? item.name.slice(0, 17) + "…"
+ : item.name;
+ return `${name.padEnd(18)} ${ver.padEnd(7)} ${status}`;
+ } else {
+ const typeTag = item.packageType === "theme"
+ ? "T"
+ : item.packageType === "language"
+ ? "L"
+ : "P";
+ const name = item.name.length > 22
+ ? item.name.slice(0, 21) + "…"
+ : item.name;
+ return `${name.padEnd(22)} [${typeTag}]`;
}
+}
- // Empty state for left panel
- if (items.length === 0) {
+/** Build the left panel (package list) using GroupedListControl */
+function buildLeftPanel(
+ installedItems: PackageListItem[],
+ availableItems: PackageListItem[],
+ allItems: PackageListItem[],
+): PanelLine[] {
+ // Empty state
+ if (allItems.length === 0) {
if (pkgState.isLoading) {
- leftLines.push({ text: "Loading...", type: "empty-state" });
+ return [{ text: "Loading...", fg: themeFg(pkgTheme.emptyState) }];
} else if (!isRegistrySynced()) {
- leftLines.push({ text: "Registry not synced", type: "empty-state" });
- leftLines.push({ text: "Tab to Sync button", type: "empty-state" });
+ return [
+ { text: "Registry not synced", fg: themeFg(pkgTheme.emptyState) },
+ { text: "Tab to Sync button", fg: themeFg(pkgTheme.emptyState) },
+ ];
} else {
- leftLines.push({ text: "No packages found", type: "empty-state" });
+ return [{ text: "No packages found", fg: themeFg(pkgTheme.emptyState) }];
}
}
- // Build right panel lines (details for selected package)
- const rightLines: Array<{ text: string; type: string; focused?: boolean; btnIndex?: number }> = [];
+ const listFocused = pkgState.focus.type === "list";
+ const groups: ListGroup[] = [];
+
+ if (installedItems.length > 0) {
+ groups.push({
+ title: `INSTALLED (${installedItems.length})`,
+ items: installedItems,
+ });
+ }
+ if (availableItems.length > 0) {
+ groups.push({
+ title: `AVAILABLE (${availableItems.length})`,
+ items: availableItems,
+ });
+ }
+
+ const list = new GroupedListControl(
+ groups,
+ formatPackageItem,
+ {
+ selectionPrefix: listFocused ? "▸ " : " ",
+ emptyPrefix: " ",
+ titleFg: themeFg(pkgTheme.sectionTitle),
+ selectedFg: themeFg(pkgTheme.selected),
+ selectedBg: themeBg(pkgTheme.selected),
+ },
+ );
+ list.selectedIndex = pkgState.selectedIndex;
+
+ // Convert renderLines output to PanelLine format
+ return list.renderLines().map((line) => ({
+ text: line.text,
+ fg: line.fg as PanelLine["fg"],
+ bg: line.bg as PanelLine["bg"],
+ }));
+}
+
+/** Build the right panel (package details) */
+function buildRightPanel(selectedItem: PackageListItem | null): PanelLine[] {
+ const lines: PanelLine[] = [];
if (selectedItem) {
// Package name
- rightLines.push({ text: selectedItem.name, type: "detail-title" });
- rightLines.push({ text: "─".repeat(Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2)), type: "detail-sep" });
+ lines.push({ text: selectedItem.name, fg: themeFg(pkgTheme.header) });
+ lines.push({
+ text: "─".repeat(
+ Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2),
+ ),
+ fg: themeFg(pkgTheme.separator),
+ });
- // Version / Author / License on one line
+ // Version / Author / License
let metaLine = `v${selectedItem.version}`;
if (selectedItem.author) metaLine += ` • ${selectedItem.author}`;
if (selectedItem.license) metaLine += ` • ${selectedItem.license}`;
- if (metaLine.length > DETAIL_WIDTH - 2) metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "...";
- rightLines.push({ text: metaLine, type: "detail-meta" });
+ if (metaLine.length > DETAIL_WIDTH - 2) {
+ metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "...";
+ }
+ lines.push({ text: metaLine, fg: themeFg(pkgTheme.infoLabel) });
- rightLines.push({ text: "", type: "blank" });
+ lines.push({ text: "" });
// Description (wrapped)
const descText = selectedItem.description || "No description available";
- const descLines = wrapText(descText, DETAIL_WIDTH - 2);
- for (const line of descLines) {
- rightLines.push({ text: line, type: "detail-desc" });
+ for (const line of wrapText(descText, DETAIL_WIDTH - 2)) {
+ lines.push({ text: line, fg: themeFg(pkgTheme.description) });
}
- rightLines.push({ text: "", type: "blank" });
+ lines.push({ text: "" });
// Keywords
if (selectedItem.keywords && selectedItem.keywords.length > 0) {
- const kwText = selectedItem.keywords.slice(0, 4).join(", ");
- rightLines.push({ text: `Tags: ${kwText}`, type: "detail-tags" });
- rightLines.push({ text: "", type: "blank" });
+ lines.push({
+ text: `Tags: ${selectedItem.keywords.slice(0, 4).join(", ")}`,
+ fg: themeFg(pkgTheme.infoLabel),
+ });
+ lines.push({ text: "" });
}
// Repository URL
if (selectedItem.repository) {
- // Shorten URL for display (remove protocol, truncate if needed)
- let displayUrl = selectedItem.repository
- .replace(/^https?:\/\//, "")
+ let displayUrl = selectedItem.repository.replace(/^https?:\/\//, "")
.replace(/\.git$/, "");
if (displayUrl.length > DETAIL_WIDTH - 2) {
displayUrl = displayUrl.slice(0, DETAIL_WIDTH - 5) + "...";
}
- rightLines.push({ text: displayUrl, type: "detail-url" });
- rightLines.push({ text: "", type: "blank" });
+ lines.push({ text: displayUrl, fg: themeFg(pkgTheme.infoLabel) });
+ lines.push({ text: "" });
}
- // Action buttons - always reserve space for brackets
- const actions = getActionButtons();
- for (let i = 0; i < actions.length; i++) {
+ // Action buttons
+ for (let i = 0; i < getActionButtons().length; i++) {
const focused = isButtonFocused("action", i);
- const leftBracket = focused ? "[" : " ";
- const rightBracket = focused ? "]" : " ";
- const btnText = `${leftBracket} ${actions[i]} ${rightBracket}`;
- rightLines.push({ text: btnText, type: "action-btn", focused, btnIndex: i });
+ const style = themeColor(
+ focused ? pkgTheme.buttonFocused : pkgTheme.button,
+ );
+ lines.push({
+ text: new ButtonControl(
+ getActionButtons()[i],
+ focused ? FocusState.Focused : FocusState.Normal,
+ ).render().text,
+ fg: style.fg,
+ bg: style.bg,
+ });
}
} else {
- rightLines.push({ text: "Select a package", type: "empty-state" });
- rightLines.push({ text: "to view details", type: "empty-state" });
- }
-
- // Merge left and right panels into rows
- const maxRows = Math.max(leftLines.length, rightLines.length, 8);
- for (let i = 0; i < maxRows; i++) {
- const leftItem = leftLines[i];
- const rightItem = rightLines[i];
-
- // Left side (padded to fixed width)
- const leftText = leftItem ? (" " + leftItem.text) : "";
- entries.push({
- text: leftText.padEnd(LIST_WIDTH),
- properties: {
- type: leftItem?.type || "blank",
- selected: leftItem?.selected,
- installed: leftItem?.installed,
- },
- });
-
- // Divider
- entries.push({ text: "│", properties: { type: "divider" } });
-
- // Right side
- const rightText = rightItem ? (" " + rightItem.text) : "";
- entries.push({
- text: rightText,
- properties: {
- type: rightItem?.type || "blank",
- focused: rightItem?.focused,
- btnIndex: rightItem?.btnIndex,
- },
- });
-
- entries.push({ text: "\n", properties: { type: "newline" } });
- }
-
- // === BOTTOM SEPARATOR ===
- entries.push({
- text: " " + "─".repeat(TOTAL_WIDTH - 2) + "\n",
- properties: { type: "separator" },
- });
-
- // === HELP LINE ===
- let helpText = " ↑↓ Navigate Tab Next / Search Enter ";
- if (pkgState.focus.type === "action") {
- helpText += "Activate";
- } else if (pkgState.focus.type === "filter") {
- helpText += "Filter";
- } else if (pkgState.focus.type === "sync") {
- helpText += "Sync";
- } else if (pkgState.focus.type === "search") {
- helpText += "Search";
- } else {
- helpText += "Select";
+ lines.push({ text: "Select a package", fg: themeFg(pkgTheme.emptyState) });
+ lines.push({ text: "to view details", fg: themeFg(pkgTheme.emptyState) });
}
- helpText += " Esc Close\n";
-
- entries.push({
- text: helpText,
- properties: { type: "help" },
- });
- return entries;
-}
-
-/**
- * Calculate UTF-8 byte length of a string.
- * Needed because string.length returns character count, not byte count.
- * Unicode chars like ▸ and ─ are 1 char but 3 bytes in UTF-8.
- */
-function utf8ByteLength(str: string): number {
- let bytes = 0;
- for (let i = 0; i < str.length; i++) {
- const code = str.charCodeAt(i);
- if (code < 0x80) {
- bytes += 1;
- } else if (code < 0x800) {
- bytes += 2;
- } else if (code >= 0xD800 && code <= 0xDBFF) {
- // Surrogate pair = 4 bytes, skip low surrogate
- bytes += 4;
- i++;
- } else {
- bytes += 3;
- }
- }
- return bytes;
-}
-
-/**
- * Apply theme-aware highlighting to the package manager view
- */
-function applyPkgManagerHighlighting(): void {
- if (pkgState.bufferId === null) return;
-
- // Clear existing overlays
- editor.clearNamespace(pkgState.bufferId, "pkg");
-
- const entries = buildListViewEntries();
- let byteOffset = 0;
-
- for (const entry of entries) {
- const props = entry.properties as Record;
- const len = utf8ByteLength(entry.text);
-
- // Determine theme colors based on entry type
- let themeStyle: ThemeColor | null = null;
-
- switch (props.type) {
- case "header":
- themeStyle = pkgTheme.header;
- break;
-
- case "section-title":
- themeStyle = pkgTheme.sectionTitle;
- break;
-
- case "filter-btn":
- if (props.focused && props.active) {
- // Both focused and active - use focused style
- themeStyle = pkgTheme.buttonFocused;
- } else if (props.focused) {
- // Only focused (not the active filter)
- themeStyle = pkgTheme.filterFocused;
- } else if (props.active) {
- // Active filter but not focused
- themeStyle = pkgTheme.filterActive;
- } else {
- themeStyle = pkgTheme.filterInactive;
- }
- break;
-
- case "sync-btn":
- themeStyle = props.focused ? pkgTheme.buttonFocused : pkgTheme.button;
- break;
-
- case "search-label":
- themeStyle = pkgTheme.infoLabel;
- break;
-
- case "search-input":
- // Search input field styling - distinct background
- themeStyle = props.focused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox;
- break;
-
- case "package-row":
- if (props.selected) {
- themeStyle = pkgTheme.selected;
- } else if (props.installed) {
- themeStyle = pkgTheme.installed;
- } else {
- themeStyle = pkgTheme.available;
- }
- break;
-
- case "detail-title":
- themeStyle = pkgTheme.header;
- break;
-
- case "detail-sep":
- case "separator":
- themeStyle = pkgTheme.separator;
- break;
-
- case "divider":
- themeStyle = pkgTheme.divider;
- break;
-
- case "detail-meta":
- case "detail-tags":
- case "detail-url":
- themeStyle = pkgTheme.infoLabel;
- break;
-
- case "detail-desc":
- themeStyle = pkgTheme.description;
- break;
-
- case "action-btn":
- themeStyle = props.focused ? pkgTheme.buttonFocused : pkgTheme.button;
- break;
-
- case "help":
- themeStyle = pkgTheme.help;
- break;
-
- case "empty-state":
- themeStyle = pkgTheme.emptyState;
- break;
- }
-
- if (themeStyle) {
- const fg = themeStyle.fg;
- const bg = themeStyle.bg;
-
- // Build overlay options - prefer theme keys, fallback to RGB
- const options: Record = {};
-
- if (fg?.theme) {
- options.fg = fg.theme;
- } else if (fg?.rgb) {
- options.fg = fg.rgb;
- }
-
- if (bg?.theme) {
- options.bg = bg.theme;
- } else if (bg?.rgb) {
- options.bg = bg.rgb;
- }
-
- if (Object.keys(options).length > 0) {
- editor.addOverlay(
- pkgState.bufferId,
- "pkg",
- byteOffset,
- byteOffset + len,
- options
- );
- }
- }
-
- byteOffset += len;
- }
+ return lines;
}
/**
* Update the package manager view
*/
function updatePkgManagerView(): void {
- if (pkgState.bufferId === null) return;
-
- const entries = buildListViewEntries();
- editor.setVirtualBufferContent(pkgState.bufferId, entries);
- applyPkgManagerHighlighting();
+ renderPkgManagerUI();
}
/**
@@ -1941,17 +2013,14 @@ async function openPackageManager(): Promise {
pkgState.items = buildPackageList();
pkgState.isLoading = false;
- // Build initial entries
- const entries = buildListViewEntries();
-
- // Create virtual buffer
+ // Create virtual buffer with placeholder content (will be rendered immediately after)
const result = await editor.createVirtualBufferInExistingSplit({
name: "*Packages*",
mode: "pkg-manager",
readOnly: true,
editingDisabled: true,
showCursors: false,
- entries: entries,
+ entries: [{ text: "", properties: {} }],
splitId: pkgState.splitId!,
showLineNumbers: false,
});
@@ -1959,8 +2028,8 @@ async function openPackageManager(): Promise {
pkgState.bufferId = result.bufferId;
pkgState.isOpen = true;
- // Apply initial highlighting
- applyPkgManagerHighlighting();
+ // Render initial UI
+ renderPkgManagerUI();
// Sync registry in background and update view when done
// User can still interact with installed packages during sync
@@ -2001,11 +2070,11 @@ function closePackageManager(): void {
function getFocusOrder(): FocusTarget[] {
const order: FocusTarget[] = [
{ type: "search" },
- { type: "filter", index: 0 }, // All
- { type: "filter", index: 1 }, // Installed
- { type: "filter", index: 2 }, // Plugins
- { type: "filter", index: 3 }, // Themes
- { type: "filter", index: 4 }, // Languages
+ { type: "filter", index: 0 }, // All
+ { type: "filter", index: 1 }, // Installed
+ { type: "filter", index: 2 }, // Plugins
+ { type: "filter", index: 3 }, // Themes
+ { type: "filter", index: 4 }, // Languages
{ type: "sync" },
{ type: "list" },
];
@@ -2038,7 +2107,7 @@ function getCurrentFocusIndex(): number {
}
// Navigation commands
-globalThis.pkg_nav_up = function(): void {
+globalThis.pkg_nav_up = function (): void {
if (!pkgState.isOpen) return;
const items = getFilteredItems();
@@ -2050,19 +2119,22 @@ globalThis.pkg_nav_up = function(): void {
updatePkgManagerView();
};
-globalThis.pkg_nav_down = function(): void {
+globalThis.pkg_nav_down = function (): void {
if (!pkgState.isOpen) return;
const items = getFilteredItems();
if (items.length === 0) return;
// Always focus list and navigate (auto-focus behavior)
- pkgState.selectedIndex = Math.min(items.length - 1, pkgState.selectedIndex + 1);
+ pkgState.selectedIndex = Math.min(
+ items.length - 1,
+ pkgState.selectedIndex + 1,
+ );
pkgState.focus = { type: "list" };
updatePkgManagerView();
};
-globalThis.pkg_next_button = function(): void {
+globalThis.pkg_next_button = function (): void {
if (!pkgState.isOpen) return;
const order = getFocusOrder();
@@ -2072,7 +2144,7 @@ globalThis.pkg_next_button = function(): void {
updatePkgManagerView();
};
-globalThis.pkg_prev_button = function(): void {
+globalThis.pkg_prev_button = function (): void {
if (!pkgState.isOpen) return;
const order = getFocusOrder();
@@ -2082,14 +2154,20 @@ globalThis.pkg_prev_button = function(): void {
updatePkgManagerView();
};
-globalThis.pkg_activate = async function(): Promise {
+globalThis.pkg_activate = async function (): Promise {
if (!pkgState.isOpen) return;
const focus = pkgState.focus;
// Handle filter button activation
if (focus.type === "filter") {
- const filters = ["all", "installed", "plugins", "themes", "languages"] as const;
+ const filters = [
+ "all",
+ "installed",
+ "plugins",
+ "themes",
+ "languages",
+ ] as const;
pkgState.filter = filters[focus.index];
pkgState.selectedIndex = 0;
pkgState.items = buildPackageList();
@@ -2145,18 +2223,25 @@ globalThis.pkg_activate = async function(): Promise {
await removePackage(item.installedPackage);
pkgState.items = buildPackageList();
const newItems = getFilteredItems();
- pkgState.selectedIndex = Math.min(pkgState.selectedIndex, Math.max(0, newItems.length - 1));
+ pkgState.selectedIndex = Math.min(
+ pkgState.selectedIndex,
+ Math.max(0, newItems.length - 1),
+ );
pkgState.focus = { type: "list" };
updatePkgManagerView();
} else if (actionName === "Install" && item.registryEntry) {
- await installPackage(item.registryEntry.repository, item.name, item.packageType);
+ await installPackage(
+ item.registryEntry.repository,
+ item.name,
+ item.packageType,
+ );
pkgState.items = buildPackageList();
updatePkgManagerView();
}
}
};
-globalThis.pkg_back_or_close = function(): void {
+globalThis.pkg_back_or_close = function (): void {
if (!pkgState.isOpen) return;
// If focus is on action buttons, go back to list
@@ -2170,28 +2255,32 @@ globalThis.pkg_back_or_close = function(): void {
closePackageManager();
};
-globalThis.pkg_scroll_up = function(): void {
+globalThis.pkg_scroll_up = function (): void {
// Just move cursor up in detail view
editor.executeAction("move_up");
};
-globalThis.pkg_scroll_down = function(): void {
+globalThis.pkg_scroll_down = function (): void {
// Just move cursor down in detail view
editor.executeAction("move_down");
};
-globalThis.pkg_search = function(): void {
+globalThis.pkg_search = function (): void {
if (!pkgState.isOpen) return;
// Pre-fill with current search query so typing replaces it
if (pkgState.searchQuery) {
- editor.startPromptWithInitial("Search packages: ", "pkg-search", pkgState.searchQuery);
+ editor.startPromptWithInitial(
+ "Search packages: ",
+ "pkg-search",
+ pkgState.searchQuery,
+ );
} else {
editor.startPrompt("Search packages: ", "pkg-search");
}
};
-globalThis.onPkgSearchConfirmed = function(args: {
+globalThis.onPkgSearchConfirmed = function (args: {
prompt_type: string;
selected_index: number | null;
input: string;
@@ -2214,13 +2303,13 @@ const registryFinder = new Finder<[string, RegistryEntry]>(editor, {
format: ([name, entry]) => ({
label: name,
description: entry.description,
- metadata: { name, entry }
+ metadata: { name, entry },
}),
preview: false,
maxResults: 100,
onSelect: async ([name, entry]) => {
await installPackage(entry.repository, name, "plugin");
- }
+ },
});
// =============================================================================
@@ -2230,14 +2319,18 @@ const registryFinder = new Finder<[string, RegistryEntry]>(editor, {
/**
* Browse and install plugins from registry
*/
-globalThis.pkg_install_plugin = async function(): Promise {
+globalThis.pkg_install_plugin = async function (): Promise {
editor.debug("[pkg] pkg_install_plugin called");
try {
// Always sync registry to ensure latest plugins are available
await syncRegistry();
const registry = loadRegistry("plugins");
- editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} packages`);
+ editor.debug(
+ `[pkg] loaded registry with ${
+ Object.keys(registry.packages).length
+ } packages`,
+ );
const entries = Object.entries(registry.packages);
editor.debug(`[pkg] entries.length = ${entries.length}`);
@@ -2253,8 +2346,8 @@ globalThis.pkg_install_plugin = async function(): Promise {
title: "Install Plugin:",
source: {
mode: "filter",
- load: async () => entries
- }
+ load: async () => entries,
+ },
});
} catch (e) {
editor.debug(`[pkg] Error in pkg_install_plugin: ${e}`);
@@ -2265,14 +2358,18 @@ globalThis.pkg_install_plugin = async function(): Promise {
/**
* Browse and install themes from registry
*/
-globalThis.pkg_install_theme = async function(): Promise {
+globalThis.pkg_install_theme = async function (): Promise {
editor.debug("[pkg] pkg_install_theme called");
try {
// Always sync registry to ensure latest themes are available
await syncRegistry();
const registry = loadRegistry("themes");
- editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} themes`);
+ editor.debug(
+ `[pkg] loaded registry with ${
+ Object.keys(registry.packages).length
+ } themes`,
+ );
const entries = Object.entries(registry.packages);
if (entries.length === 0) {
@@ -2284,8 +2381,8 @@ globalThis.pkg_install_theme = async function(): Promise {
title: "Install Theme:",
source: {
mode: "filter",
- load: async () => entries
- }
+ load: async () => entries,
+ },
});
} catch (e) {
editor.debug(`[pkg] Error in pkg_install_theme: ${e}`);
@@ -2296,11 +2393,11 @@ globalThis.pkg_install_theme = async function(): Promise {
/**
* Install from git URL
*/
-globalThis.pkg_install_url = function(): void {
+globalThis.pkg_install_url = function (): void {
editor.startPrompt("Git URL:", "pkg-install-url");
};
-globalThis.onPkgInstallUrlConfirmed = async function(args: {
+globalThis.onPkgInstallUrlConfirmed = async function (args: {
prompt_type: string;
selected_index: number | null;
input: string;
@@ -2322,21 +2419,21 @@ editor.on("prompt_confirmed", "onPkgInstallUrlConfirmed");
/**
* Open the package manager UI
*/
-globalThis.pkg_list = async function(): Promise {
+globalThis.pkg_list = async function (): Promise {
await openPackageManager();
};
/**
* Update all packages
*/
-globalThis.pkg_update_all = async function(): Promise {
+globalThis.pkg_update_all = async function (): Promise {
await updateAllPackages();
};
/**
* Update a specific package
*/
-globalThis.pkg_update = function(): void {
+globalThis.pkg_update = function (): void {
const plugins = getInstalledPackages("plugin");
const themes = getInstalledPackages("theme");
const all = [...plugins, ...themes];
@@ -2351,27 +2448,27 @@ globalThis.pkg_update = function(): void {
format: (pkg) => ({
label: pkg.name,
description: `${pkg.type} | ${pkg.version}`,
- metadata: pkg
+ metadata: pkg,
}),
preview: false,
onSelect: async (pkg) => {
await updatePackage(pkg);
- }
+ },
});
finder.prompt({
title: "Update Package:",
source: {
mode: "filter",
- load: async () => all
- }
+ load: async () => all,
+ },
});
};
/**
* Remove a package
*/
-globalThis.pkg_remove = function(): void {
+globalThis.pkg_remove = function (): void {
const plugins = getInstalledPackages("plugin");
const themes = getInstalledPackages("theme");
const all = [...plugins, ...themes];
@@ -2386,34 +2483,34 @@ globalThis.pkg_remove = function(): void {
format: (pkg) => ({
label: pkg.name,
description: `${pkg.type} | ${pkg.version}`,
- metadata: pkg
+ metadata: pkg,
}),
preview: false,
onSelect: async (pkg) => {
await removePackage(pkg);
- }
+ },
});
finder.prompt({
title: "Remove Package:",
source: {
mode: "filter",
- load: async () => all
- }
+ load: async () => all,
+ },
});
};
/**
* Sync registry
*/
-globalThis.pkg_sync = async function(): Promise {
+globalThis.pkg_sync = async function (): Promise {
await syncRegistry();
};
/**
* Show outdated packages
*/
-globalThis.pkg_outdated = async function(): Promise {
+globalThis.pkg_outdated = async function (): Promise {
const plugins = getInstalledPackages("plugin");
const themes = getInstalledPackages("theme");
const all = [...plugins, ...themes];
@@ -2433,7 +2530,11 @@ globalThis.pkg_outdated = async function(): Promise {
// Check how many commits behind
const result = await gitCommand([
- "-C", `${pkg.path}`, "rev-list", "--count", "HEAD..origin/HEAD"
+ "-C",
+ `${pkg.path}`,
+ "rev-list",
+ "--count",
+ "HEAD..origin/HEAD",
]);
const behind = parseInt(result.stdout.trim(), 10);
@@ -2452,34 +2553,34 @@ globalThis.pkg_outdated = async function(): Promise {
format: (item) => ({
label: item.pkg.name,
description: `${item.behind} commits behind`,
- metadata: item
+ metadata: item,
}),
preview: false,
onSelect: async (item) => {
await updatePackage(item.pkg);
- }
+ },
});
finder.prompt({
title: `Outdated Packages (${outdated.length}):`,
source: {
mode: "filter",
- load: async () => outdated
- }
+ load: async () => outdated,
+ },
});
};
/**
* Generate lockfile
*/
-globalThis.pkg_lock = async function(): Promise {
+globalThis.pkg_lock = async function (): Promise {
await generateLockfile();
};
/**
* Install from lockfile
*/
-globalThis.pkg_install_lock = async function(): Promise {
+globalThis.pkg_install_lock = async function (): Promise {
await installFromLockfile();
};
@@ -2491,7 +2592,12 @@ globalThis.pkg_install_lock = async function(): Promise {
editor.registerCommand("%cmd.list", "%cmd.list_desc", "pkg_list", null);
// Install from URL - for packages not in registry
-editor.registerCommand("%cmd.install_url", "%cmd.install_url_desc", "pkg_install_url", null);
+editor.registerCommand(
+ "%cmd.install_url",
+ "%cmd.install_url_desc",
+ "pkg_install_url",
+ null,
+);
// Note: Other commands (install_plugin, install_theme, update, remove, sync, etc.)
// are available via the package manager UI and don't need global command palette entries.
diff --git a/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md b/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md
index 82c202259..b6c0b8210 100644
--- a/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md
+++ b/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md
@@ -1164,14 +1164,14 @@ The **Layout DSL** (Part 5) is a future direction that adds compositional UI bui
## Files to Create
-| File | Purpose | Lines (est.) |
-|------|---------|--------------|
-| `src/view/ui/layout.rs` | `point_in_rect()` helper | ~30 |
-| `src/view/ui/focus.rs` | `FocusManager` | ~60 |
-| `plugins/lib/controls.ts` | `ButtonControl`, `ListControl`, `FocusManager` | ~200 |
-| `plugins/lib/vbuffer.ts` | `VirtualBufferBuilder` | ~100 |
+| File | Purpose | Lines (est.) | Status |
+|------|---------|--------------|--------|
+| `src/view/ui/layout.rs` | `point_in_rect()` helper | ~30 | ✅ Done |
+| `src/view/ui/focus.rs` | `FocusManager` | ~60 | ✅ Done |
+| `plugins/lib/controls.ts` | `ButtonControl`, `ListControl`, `FocusManager`, etc. | ~550 | ✅ Done |
+| `plugins/lib/vbuffer.ts` | `VirtualBufferBuilder` | ~300 | ✅ Done |
-**Total new code: ~390 lines** (mostly TypeScript for plugins)
+**Total new code: ~940 lines** (expanded TypeScript library with more controls)
## Files to Modify
@@ -1215,6 +1215,10 @@ The key principle: **extract existing code into shared modules first**, then hav
| 5 | Migrate `settings/state.rs` to use `FocusManager` | ✅ Done |
| 6 | Add `MenuLayout` + `MenuHit` to `menu.rs` | ✅ Done |
| 7 | Add `TabLayout` + `TabHit` to `tabs.rs` | ✅ Done |
+| 8 | Create `plugins/lib/controls.ts` (ButtonControl, ListControl, FocusManager) | ✅ Done |
+| 9 | Create `plugins/lib/vbuffer.ts` (VirtualBufferBuilder) | ✅ Done |
+| 10 | Update `plugins/lib/index.ts` exports | ✅ Done |
+| 11 | Migrate `pkg.ts` to use controls library | ✅ Done |
---