From d625be94a52a74e226f7f94728b56ef8fe322749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 26 Jun 2026 18:49:51 -0700 Subject: [PATCH 1/2] feat(renderer): let widgets opt out of powerline auto-align In powerline auto-align mode every widget is padded so its column lines up with the widget above and below it. A single wide widget, such as a long git branch, therefore stretches that column on every other line and wastes horizontal space. Add an `excludeFromAutoAlign` flag. A flagged widget, and everything after it on the same line, stops contributing to the shared column widths and stops receiving alignment padding, so it keeps its natural width while the columns before it stay aligned. The items editor exposes an `e(x)clude align` toggle and a `(no-align)` marker, shown only when powerline auto-align is on. --- src/tui/components/ItemsEditor.tsx | 6 ++ .../components/items-editor/input-handlers.ts | 13 +++ src/types/Widget.ts | 1 + .../renderer-exclude-auto-align.test.ts | 82 +++++++++++++++++++ src/utils/renderer.ts | 10 +++ 5 files changed, 112 insertions(+) create mode 100644 src/utils/__tests__/renderer-exclude-auto-align.test.ts diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index a349b260..03227285 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -271,6 +271,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; + const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator + && settings.powerline.enabled && settings.powerline.autoAlign; const hasWidgets = widgets.length > 0; // Build main help text (without custom keybinds) @@ -289,6 +291,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (canMerge) { helpText += ', (m)erge'; } + if (canExcludeAlign) { + helpText += ', e(x)clude align'; + } helpText += ', ESC back'; // Build custom keybinds text @@ -547,6 +552,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (merged→)} {widget.merge === 'no-padding' && (merged-no-pad→)} + {widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && (no-align)} ); })} diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 989b4c16..f9cfa06a 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -455,6 +455,19 @@ export function handleNormalInputMode({ } onUpdate(newWidgets); } + } else if (input === 'x' && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + const newWidgets = [...widgets]; + if (currentWidget.excludeFromAutoAlign) { + const { excludeFromAutoAlign, ...rest } = currentWidget; + void excludeFromAutoAlign; // Intentionally unused + newWidgets[selectedIndex] = rest; + } else { + newWidgets[selectedIndex] = { ...currentWidget, excludeFromAutoAlign: true }; + } + onUpdate(newWidgets); + } } else if (key.escape) { onBack(); } else if (widgets.length > 0) { diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 121c2a9b..ad2bf571 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -21,6 +21,7 @@ export const WidgetItemSchema = z.object({ timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), hide: z.boolean().optional(), + excludeFromAutoAlign: z.boolean().optional(), metadata: z.record(z.string(), z.string()).optional() }); diff --git a/src/utils/__tests__/renderer-exclude-auto-align.test.ts b/src/utils/__tests__/renderer-exclude-auto-align.test.ts new file mode 100644 index 00000000..cd9e02ea --- /dev/null +++ b/src/utils/__tests__/renderer-exclude-auto-align.test.ts @@ -0,0 +1,82 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { getVisibleWidth } from '../ansi'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine, + type PreRenderedWidget +} from '../renderer'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + defaultPadding: '', + flexMode: 'full', + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +function pre(content: string, extra: Partial = {}): PreRenderedWidget { + return { content, plainLength: content.length, widget: { id: content, type: 'custom-text', ...extra } }; +} + +function text(content: string, extra: Partial = {}): WidgetItem { + return { id: content, type: 'custom-text', customText: content, ...extra }; +} + +describe('calculateMaxWidthsFromPreRendered with excludeFromAutoAlign', () => { + it.each([ + { name: 'lets a wide widget inflate the shared column by default', exclude: false, expected: [5, 14] }, + { name: 'drops an excluded widget and the rest of its line', exclude: true, expected: [5, 1] } + ])('$name', ({ exclude, expected }) => { + const lines = [ + [pre('short'), pre('VERYLONGWIDGET', exclude ? { excludeFromAutoAlign: true } : {})], + [pre('x'), pre('y')] + ]; + + expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual(expected); + }); + + it('keeps columns before the excluded widget aligned', () => { + const lines = [ + [pre('a'), pre('wide', { excludeFromAutoAlign: true }), pre('tail')], + [pre('AAAAA'), pre('BBBBB'), pre('CCCCC')] + ]; + + expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([5, 5, 5]); + }); +}); + +describe('renderStatusLine auto-align exemption', () => { + const settings = createSettings({ powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true, autoAlign: true } }); + + function renderFirstLine(exclude: boolean): string { + const lines = [ + [text('a'), text('y', exclude ? { excludeFromAutoAlign: true } : {}), text('z')], + [text('AAAAA'), text('BBBBB'), text('CCCCC')] + ]; + const context: RenderContext = { isPreview: false, terminalWidth: 200, lineIndex: 0 }; + const preRendered = preRenderAllWidgets(lines, settings, context); + const maxWidths = calculateMaxWidthsFromPreRendered(preRendered, settings); + return renderStatusLine(lines[0] ?? [], settings, context, preRendered[0] ?? [], maxWidths); + } + + it('exempts the excluded widget and the rest of its line from alignment padding', () => { + expect(getVisibleWidth(renderFirstLine(false))).toBeGreaterThan(getVisibleWidth(renderFirstLine(true))); + }); +}); diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 47ac3914..c7ac1976 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -376,6 +376,11 @@ function renderPowerlineStatusLine( if (!element) continue; + // An excluded widget, and everything after it on this line, keeps its + // natural width (mirrors the skip in calculateMaxWidthsFromPreRendered). + if (element.widget.excludeFromAutoAlign) + break; + // Check if previous widget was merged with this one const prevWidget = i > 0 ? widgetElements[i - 1] : null; const isPreviousMerged = prevWidget?.mergesWithNext; @@ -854,6 +859,11 @@ export function calculateMaxWidthsFromPreRendered( if (!widget) continue; + // An excluded widget opts itself and the rest of the line out of the + // shared column widths, so a wide widget cannot stretch other lines. + if (widget.widget.excludeFromAutoAlign) + break; + // Calculate the total width for this alignment position // If this widget is merged with the next, accumulate their widths let totalWidth = widget.plainLength + (paddingLength * 2); From 0e84516d7c4355029826ff88ff40b598142dec24 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Tue, 30 Jun 2026 12:06:40 -0400 Subject: [PATCH 2/2] fix(renderer): constrain no-align to alignable widgets Only allow excludeFromAutoAlign to be toggled from the items editor when powerline auto-align is active and the selected widget is not merged into a previous widget. This keeps the shortcut behavior aligned with the visible help text and prevents hidden exclusions from being persisted while the feature is unavailable. Treat no-align as a merge-chain-head option in the UI, so widgets merged into a previous item no longer show an effective no-align marker. The renderer keeps merged-in widgets participating in their parent group width while still honoring exclusions on the first widget in the chain. Add regression coverage for merged no-align width calculation and for the editor shortcut gate. --- src/tui/components/ItemsEditor.tsx | 59 +++++++++++-------- .../__tests__/input-handlers.test.ts | 53 +++++++++++++++++ .../components/items-editor/input-handlers.ts | 4 +- .../renderer-exclude-auto-align.test.ts | 23 ++++++++ src/utils/renderer.ts | 3 +- 5 files changed, 115 insertions(+), 27 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 03227285..b9257ec3 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -42,6 +42,14 @@ export interface ItemsEditorProps { settings: Settings; } +function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean { + if (index <= 0) { + return false; + } + + return Boolean(widgets[index - 1]?.merge); +} + export const ItemsEditor: React.FC = ({ widgets, onUpdate, onBack, lineNumber, settings }) => { const [selectedIndex, setSelectedIndex] = useState(0); const [moveMode, setMoveMode] = useState(false); @@ -152,6 +160,30 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setWidgetPicker(null); }; + const currentWidget = widgets[selectedIndex]; + const isSeparator = currentWidget?.type === 'separator'; + const isFlexSeparator = currentWidget?.type === 'flex-separator'; + + // Check if widget supports raw value using registry + let canToggleRaw = false; + let customKeybinds: CustomKeybind[] = []; + if (currentWidget && !isSeparator && !isFlexSeparator) { + const widgetImpl = getWidget(currentWidget.type); + if (widgetImpl) { + canToggleRaw = widgetImpl.supportsRawValue(); + // Get custom keybinds from the widget + customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); + } else { + canToggleRaw = false; + } + } + + const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; + const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator + && settings.powerline.enabled && settings.powerline.autoAlign + && !isMergedIntoPreviousWidget(widgets, selectedIndex); + const hasWidgets = widgets.length > 0; + useInput((input, key) => { // Skip input if custom editor is active if (customEditorWidget) { @@ -193,6 +225,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB key, widgets, selectedIndex, + canExcludeAlign, separatorChars, onBack, onUpdate, @@ -251,30 +284,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0]) : null; - // Build dynamic help text based on selected item - const currentWidget = widgets[selectedIndex]; - const isSeparator = currentWidget?.type === 'separator'; - const isFlexSeparator = currentWidget?.type === 'flex-separator'; - - // Check if widget supports raw value using registry - let canToggleRaw = false; - let customKeybinds: CustomKeybind[] = []; - if (currentWidget && !isSeparator && !isFlexSeparator) { - const widgetImpl = getWidget(currentWidget.type); - if (widgetImpl) { - canToggleRaw = widgetImpl.supportsRawValue(); - // Get custom keybinds from the widget - customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); - } else { - canToggleRaw = false; - } - } - - const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; - const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator - && settings.powerline.enabled && settings.powerline.autoAlign; - const hasWidgets = widgets.length > 0; - // Build main help text (without custom keybinds) let helpText = hasWidgets ? '↑↓ select, ←→ open type picker' @@ -552,7 +561,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (merged→)} {widget.merge === 'no-padding' && (merged-no-pad→)} - {widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && (no-align)} + {widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && (no-align)} ); })} diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 45dd555c..486d2f79 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -532,6 +532,59 @@ describe('items-editor input handlers', () => { expect(updated?.[0]?.character).toBe('-'); }); + it('toggles auto-align exclusion when the editor marks it available', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: 'x', + key: {}, + widgets, + selectedIndex: 0, + canExcludeAlign: true, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn() + }); + + const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; + expect(updated?.[0]?.excludeFromAutoAlign).toBe(true); + }); + + it('ignores the auto-align exclusion shortcut when the editor marks it unavailable', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' } + ]; + const onUpdate = vi.fn(); + + handleNormalInputMode({ + input: 'x', + key: {}, + widgets, + selectedIndex: 0, + canExcludeAlign: false, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], + setCustomEditorWidget: vi.fn() + }); + + expect(onUpdate).not.toHaveBeenCalled(); + }); + it('applies custom widget keybind actions in normal mode', () => { const widgets: WidgetItem[] = [ { id: '1', type: 'session-usage' } diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index f9cfa06a..fab43416 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -338,6 +338,7 @@ export interface HandleNormalInputModeArgs { key: InputKey; widgets: WidgetItem[]; selectedIndex: number; + canExcludeAlign?: boolean; separatorChars: string[]; onBack: () => void; onUpdate: (widgets: WidgetItem[]) => void; @@ -355,6 +356,7 @@ export function handleNormalInputMode({ key, widgets, selectedIndex, + canExcludeAlign = false, separatorChars, onBack, onUpdate, @@ -457,7 +459,7 @@ export function handleNormalInputMode({ } } else if (input === 'x' && widgets.length > 0) { const currentWidget = widgets[selectedIndex]; - if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + if (canExcludeAlign && currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { const newWidgets = [...widgets]; if (currentWidget.excludeFromAutoAlign) { const { excludeFromAutoAlign, ...rest } = currentWidget; diff --git a/src/utils/__tests__/renderer-exclude-auto-align.test.ts b/src/utils/__tests__/renderer-exclude-auto-align.test.ts index cd9e02ea..098dcd8e 100644 --- a/src/utils/__tests__/renderer-exclude-auto-align.test.ts +++ b/src/utils/__tests__/renderer-exclude-auto-align.test.ts @@ -60,6 +60,29 @@ describe('calculateMaxWidthsFromPreRendered with excludeFromAutoAlign', () => { expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([5, 5, 5]); }); + + it('ignores exclusions on widgets merged into a previous widget', () => { + const linesWithoutExclude = [ + [pre('a', { merge: true }), pre('VERYLONGWIDGET')], + [pre('x'), pre('y')] + ]; + const linesWithMergedExclude = [ + [pre('a', { merge: true }), pre('VERYLONGWIDGET', { excludeFromAutoAlign: true })], + [pre('x'), pre('y')] + ]; + + expect(calculateMaxWidthsFromPreRendered(linesWithMergedExclude, createSettings())) + .toEqual(calculateMaxWidthsFromPreRendered(linesWithoutExclude, createSettings())); + }); + + it('honors exclusions on the first widget in a merged chain', () => { + const lines = [ + [pre('a', { merge: true, excludeFromAutoAlign: true }), pre('VERYLONGWIDGET')], + [pre('x'), pre('y')] + ]; + + expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([1, 1]); + }); }); describe('renderStatusLine auto-align exemption', () => { diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index c7ac1976..fe022737 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -860,7 +860,8 @@ export function calculateMaxWidthsFromPreRendered( continue; // An excluded widget opts itself and the rest of the line out of the - // shared column widths, so a wide widget cannot stretch other lines. + // shared column widths. This only applies to merge-group heads; + // widgets merged into a previous widget keep the group's width. if (widget.widget.excludeFromAutoAlign) break;