Skip to content

Commit 95d2d6d

Browse files
committed
Merge remote-tracking branch 'origin/main' into pull-requests/pr488
2 parents 53b9546 + c20e111 commit 95d2d6d

20 files changed

Lines changed: 539 additions & 134 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
name: Lint & Type Check
1111
runs-on: ubuntu-latest
1212
steps:
13-
- uses: actions/checkout@v6
13+
- uses: actions/checkout@v7
1414
- uses: oven-sh/setup-bun@v2
1515
- run: bun install
1616
- run: bun run lint
@@ -19,7 +19,7 @@ jobs:
1919
name: Test
2020
runs-on: ubuntu-latest
2121
steps:
22-
- uses: actions/checkout@v6
22+
- uses: actions/checkout@v7
2323
- uses: oven-sh/setup-bun@v2
2424
- run: bun install
2525
- run: bun test
@@ -29,7 +29,7 @@ jobs:
2929
runs-on: ubuntu-latest
3030
needs: [lint, test]
3131
steps:
32-
- uses: actions/checkout@v6
32+
- uses: actions/checkout@v7
3333
- uses: oven-sh/setup-bun@v2
3434
- run: bun install
3535
- run: bun run build

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
name: Publish to npm
1616
runs-on: ubuntu-latest
1717
steps:
18-
- uses: actions/checkout@v6
18+
- uses: actions/checkout@v7
1919
- uses: oven-sh/setup-bun@v2
2020
- uses: actions/setup-node@v6
2121
with:

bun.lock

Lines changed: 87 additions & 87 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/USAGE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ Widget-specific shortcuts:
209209
- **Compaction Counter**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed, `h` hide when zero
210210
- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope, `h` hide when empty
211211
- **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons
212-
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
212+
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path, `g` optional leading glyph (off by default; pair with raw value to replace the `cwd:` label with the glyph)
213213
- **Skills**: `v` cycle view mode, `h` hide when empty, `l` edit list limit in list mode
214214
- **Input Speed / Output Speed / Total Speed**: `w` edit the rolling window in seconds
215215
- **Custom Text / Custom Symbol**: `e` edit text or symbol

src/tui/components/ItemsEditor.tsx

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ export interface ItemsEditorProps {
4242
settings: Settings;
4343
}
4444

45+
function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean {
46+
if (index <= 0) {
47+
return false;
48+
}
49+
50+
return Boolean(widgets[index - 1]?.merge);
51+
}
52+
4553
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
4654
const [selectedIndex, setSelectedIndex] = useState(0);
4755
const [moveMode, setMoveMode] = useState(false);
@@ -152,6 +160,30 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
152160
setWidgetPicker(null);
153161
};
154162

163+
const currentWidget = widgets[selectedIndex];
164+
const isSeparator = currentWidget?.type === 'separator';
165+
const isFlexSeparator = currentWidget?.type === 'flex-separator';
166+
167+
// Check if widget supports raw value using registry
168+
let canToggleRaw = false;
169+
let customKeybinds: CustomKeybind[] = [];
170+
if (currentWidget && !isSeparator && !isFlexSeparator) {
171+
const widgetImpl = getWidget(currentWidget.type);
172+
if (widgetImpl) {
173+
canToggleRaw = widgetImpl.supportsRawValue();
174+
// Get custom keybinds from the widget
175+
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
176+
} else {
177+
canToggleRaw = false;
178+
}
179+
}
180+
181+
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
182+
const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator
183+
&& settings.powerline.enabled && settings.powerline.autoAlign
184+
&& !isMergedIntoPreviousWidget(widgets, selectedIndex);
185+
const hasWidgets = widgets.length > 0;
186+
155187
useInput((input, key) => {
156188
// Skip input if custom editor is active
157189
if (customEditorWidget) {
@@ -193,6 +225,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
193225
key,
194226
widgets,
195227
selectedIndex,
228+
canExcludeAlign,
196229
separatorChars,
197230
onBack,
198231
onUpdate,
@@ -251,28 +284,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
251284
? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0])
252285
: null;
253286

254-
// Build dynamic help text based on selected item
255-
const currentWidget = widgets[selectedIndex];
256-
const isSeparator = currentWidget?.type === 'separator';
257-
const isFlexSeparator = currentWidget?.type === 'flex-separator';
258-
259-
// Check if widget supports raw value using registry
260-
let canToggleRaw = false;
261-
let customKeybinds: CustomKeybind[] = [];
262-
if (currentWidget && !isSeparator && !isFlexSeparator) {
263-
const widgetImpl = getWidget(currentWidget.type);
264-
if (widgetImpl) {
265-
canToggleRaw = widgetImpl.supportsRawValue();
266-
// Get custom keybinds from the widget
267-
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
268-
} else {
269-
canToggleRaw = false;
270-
}
271-
}
272-
273-
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
274-
const hasWidgets = widgets.length > 0;
275-
276287
// Build main help text (without custom keybinds)
277288
let helpText = hasWidgets
278289
? '↑↓ select, ←→ open type picker'
@@ -289,6 +300,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
289300
if (canMerge) {
290301
helpText += ', (m)erge';
291302
}
303+
if (canExcludeAlign) {
304+
helpText += ', e(x)clude align';
305+
}
292306
helpText += ', ESC back';
293307

294308
// Build custom keybinds text
@@ -547,6 +561,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
547561
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
548562
{widget.merge === true && <Text dimColor> (merged→)</Text>}
549563
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
564+
{widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && <Text dimColor> (no-align)</Text>}
550565
</Box>
551566
);
552567
})}

src/tui/components/items-editor/__tests__/input-handlers.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,59 @@ describe('items-editor input handlers', () => {
532532
expect(updated?.[0]?.character).toBe('-');
533533
});
534534

535+
it('toggles auto-align exclusion when the editor marks it available', () => {
536+
const widgets: WidgetItem[] = [
537+
{ id: '1', type: 'tokens-input' }
538+
];
539+
const onUpdate = vi.fn();
540+
541+
handleNormalInputMode({
542+
input: 'x',
543+
key: {},
544+
widgets,
545+
selectedIndex: 0,
546+
canExcludeAlign: true,
547+
separatorChars: ['|', '-'],
548+
onBack: vi.fn(),
549+
onUpdate,
550+
setSelectedIndex: vi.fn(),
551+
setMoveMode: vi.fn(),
552+
setShowClearConfirm: vi.fn(),
553+
openWidgetPicker: vi.fn(),
554+
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
555+
setCustomEditorWidget: vi.fn()
556+
});
557+
558+
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
559+
expect(updated?.[0]?.excludeFromAutoAlign).toBe(true);
560+
});
561+
562+
it('ignores the auto-align exclusion shortcut when the editor marks it unavailable', () => {
563+
const widgets: WidgetItem[] = [
564+
{ id: '1', type: 'tokens-input' }
565+
];
566+
const onUpdate = vi.fn();
567+
568+
handleNormalInputMode({
569+
input: 'x',
570+
key: {},
571+
widgets,
572+
selectedIndex: 0,
573+
canExcludeAlign: false,
574+
separatorChars: ['|', '-'],
575+
onBack: vi.fn(),
576+
onUpdate,
577+
setSelectedIndex: vi.fn(),
578+
setMoveMode: vi.fn(),
579+
setShowClearConfirm: vi.fn(),
580+
openWidgetPicker: vi.fn(),
581+
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
582+
setCustomEditorWidget: vi.fn()
583+
});
584+
585+
expect(onUpdate).not.toHaveBeenCalled();
586+
});
587+
535588
it('applies custom widget keybind actions in normal mode', () => {
536589
const widgets: WidgetItem[] = [
537590
{ id: '1', type: 'session-usage' }

src/tui/components/items-editor/input-handlers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ export interface HandleNormalInputModeArgs {
338338
key: InputKey;
339339
widgets: WidgetItem[];
340340
selectedIndex: number;
341+
canExcludeAlign?: boolean;
341342
separatorChars: string[];
342343
onBack: () => void;
343344
onUpdate: (widgets: WidgetItem[]) => void;
@@ -355,6 +356,7 @@ export function handleNormalInputMode({
355356
key,
356357
widgets,
357358
selectedIndex,
359+
canExcludeAlign = false,
358360
separatorChars,
359361
onBack,
360362
onUpdate,
@@ -455,6 +457,19 @@ export function handleNormalInputMode({
455457
}
456458
onUpdate(newWidgets);
457459
}
460+
} else if (input === 'x' && widgets.length > 0) {
461+
const currentWidget = widgets[selectedIndex];
462+
if (canExcludeAlign && currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
463+
const newWidgets = [...widgets];
464+
if (currentWidget.excludeFromAutoAlign) {
465+
const { excludeFromAutoAlign, ...rest } = currentWidget;
466+
void excludeFromAutoAlign; // Intentionally unused
467+
newWidgets[selectedIndex] = rest;
468+
} else {
469+
newWidgets[selectedIndex] = { ...currentWidget, excludeFromAutoAlign: true };
470+
}
471+
onUpdate(newWidgets);
472+
}
458473
} else if (key.escape) {
459474
onBack();
460475
} else if (widgets.length > 0) {

src/types/Widget.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const WidgetItemSchema = z.object({
2121
timeout: z.number().optional(),
2222
merge: z.union([z.boolean(), z.literal('no-padding')]).optional(),
2323
hide: z.boolean().optional(),
24+
excludeFromAutoAlign: z.boolean().optional(),
2425
metadata: z.record(z.string(), z.string()).optional()
2526
});
2627

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
describe,
3+
expect,
4+
it
5+
} from 'vitest';
6+
7+
import type { RenderContext } from '../../types/RenderContext';
8+
import {
9+
DEFAULT_SETTINGS,
10+
type Settings
11+
} from '../../types/Settings';
12+
import type { WidgetItem } from '../../types/Widget';
13+
import { getVisibleWidth } from '../ansi';
14+
import {
15+
calculateMaxWidthsFromPreRendered,
16+
preRenderAllWidgets,
17+
renderStatusLine,
18+
type PreRenderedWidget
19+
} from '../renderer';
20+
21+
function createSettings(overrides: Partial<Settings> = {}): Settings {
22+
return {
23+
...DEFAULT_SETTINGS,
24+
defaultPadding: '',
25+
flexMode: 'full',
26+
...overrides,
27+
powerline: {
28+
...DEFAULT_SETTINGS.powerline,
29+
...(overrides.powerline ?? {})
30+
}
31+
};
32+
}
33+
34+
function pre(content: string, extra: Partial<WidgetItem> = {}): PreRenderedWidget {
35+
return { content, plainLength: content.length, widget: { id: content, type: 'custom-text', ...extra } };
36+
}
37+
38+
function text(content: string, extra: Partial<WidgetItem> = {}): WidgetItem {
39+
return { id: content, type: 'custom-text', customText: content, ...extra };
40+
}
41+
42+
describe('calculateMaxWidthsFromPreRendered with excludeFromAutoAlign', () => {
43+
it.each([
44+
{ name: 'lets a wide widget inflate the shared column by default', exclude: false, expected: [5, 14] },
45+
{ name: 'drops an excluded widget and the rest of its line', exclude: true, expected: [5, 1] }
46+
])('$name', ({ exclude, expected }) => {
47+
const lines = [
48+
[pre('short'), pre('VERYLONGWIDGET', exclude ? { excludeFromAutoAlign: true } : {})],
49+
[pre('x'), pre('y')]
50+
];
51+
52+
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual(expected);
53+
});
54+
55+
it('keeps columns before the excluded widget aligned', () => {
56+
const lines = [
57+
[pre('a'), pre('wide', { excludeFromAutoAlign: true }), pre('tail')],
58+
[pre('AAAAA'), pre('BBBBB'), pre('CCCCC')]
59+
];
60+
61+
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([5, 5, 5]);
62+
});
63+
64+
it('ignores exclusions on widgets merged into a previous widget', () => {
65+
const linesWithoutExclude = [
66+
[pre('a', { merge: true }), pre('VERYLONGWIDGET')],
67+
[pre('x'), pre('y')]
68+
];
69+
const linesWithMergedExclude = [
70+
[pre('a', { merge: true }), pre('VERYLONGWIDGET', { excludeFromAutoAlign: true })],
71+
[pre('x'), pre('y')]
72+
];
73+
74+
expect(calculateMaxWidthsFromPreRendered(linesWithMergedExclude, createSettings()))
75+
.toEqual(calculateMaxWidthsFromPreRendered(linesWithoutExclude, createSettings()));
76+
});
77+
78+
it('honors exclusions on the first widget in a merged chain', () => {
79+
const lines = [
80+
[pre('a', { merge: true, excludeFromAutoAlign: true }), pre('VERYLONGWIDGET')],
81+
[pre('x'), pre('y')]
82+
];
83+
84+
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([1, 1]);
85+
});
86+
});
87+
88+
describe('renderStatusLine auto-align exemption', () => {
89+
const settings = createSettings({ powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true, autoAlign: true } });
90+
91+
function renderFirstLine(exclude: boolean): string {
92+
const lines = [
93+
[text('a'), text('y', exclude ? { excludeFromAutoAlign: true } : {}), text('z')],
94+
[text('AAAAA'), text('BBBBB'), text('CCCCC')]
95+
];
96+
const context: RenderContext = { isPreview: false, terminalWidth: 200, lineIndex: 0 };
97+
const preRendered = preRenderAllWidgets(lines, settings, context);
98+
const maxWidths = calculateMaxWidthsFromPreRendered(preRendered, settings);
99+
return renderStatusLine(lines[0] ?? [], settings, context, preRendered[0] ?? [], maxWidths);
100+
}
101+
102+
it('exempts the excluded widget and the rest of its line from alignment padding', () => {
103+
expect(getVisibleWidth(renderFirstLine(false))).toBeGreaterThan(getVisibleWidth(renderFirstLine(true)));
104+
});
105+
});

0 commit comments

Comments
 (0)