Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ Configure global formatting preferences that apply to all widgets:
- Press **(s)** to edit separator
- Manual separators look past widgets that render empty, so the intended separator remains between visible neighbors without duplicating an earlier visible boundary. Inherited separator colors come from the actual preceding visible widget.

<details>
<summary><b>Per-widget width overhead (audit)</b></summary>

- **Default padding is empty** (`defaultPadding` unset), so widgets add no padding unless you configure it. A non-empty padding of N characters adds up to 2N columns per widget (one per enabled side).
- **Manual `|` separators render as ` | `** (3 columns); in Powerline mode separator widgets are ignored and replaced by the powerline separators.
- **Labels are the dominant fixed cost** of labeled widgets. Defaults: `Model: ` (7), `Ctx: ` (5), `Cost: ` (6), token widgets `In: `/`Out: `/`Total: `/`Cached: ` (4–8), cache widgets `Cache Read: `/`Cache Write: `/`Cache Hit: ` (10–12), timers `Block: `/`Reset: `/`Cache: ` (7) and `Weekly Reset: ` (13), usage widgets `Session: `/`Weekly: `/`Weekly Sonnet: `/`Weekly Opus: `/`Weekly Fable: ` (8–15).
- **Compact Labels** (above) trims the presets it covers (`Model:` −4, `Context:` −4, `Cost:` −6, label gone — the value's own `$` remains); Minimalist Mode strips labels entirely.
- **Unbounded-content widgets** (paths, names, URLs) can overflow a narrow terminal: Git Branch, Git Root Dir, Current Working Dir, and Session Name support a per-widget max-width cap — select the widget in the line editor and press **(w)idth**. The line renderer truncates the whole line with an ellipsis regardless.

</details>

<details>
<summary><b>Global Formatting Options</b></summary>

Expand All @@ -122,6 +133,10 @@ Configure global formatting preferences that apply to all widgets:
- Press **(o)** to toggle
- **Minimalist Mode** - Force widgets into raw-value rendering globally for a cleaner, label-free status line
- Press **(m)** to toggle
- **Compact Labels** - Use short label presets on labeled widgets: `Model:` → `M:`, `Context:` → `Ctx:`, `Cost:` → `$`
- Press **(j)** to toggle; off by default, so existing configs render exactly as before
- Per-widget override: select a labeled widget in the line editor and press **(j) compact label** to force it on/off regardless of the global setting
- Labels without a preset (e.g. `In:`, `Cached:`) keep their default form; presets live in `COMPACT_LABELS` in `src/widgets/shared/raw-or-labeled.ts`
- **Number Formatting** - Choose precise, compact, or whole-number output independently for token, speed, percent, memory, and cost values
- Press **(n)** to configure each number type; a global choice overrides per-widget formatting for that type
- **Override Foreground Color** - Force all widgets to use the same text color, or a whole-line **gradient** (see below)
Expand Down Expand Up @@ -251,6 +266,7 @@ Common controls in the line editor:
- `Space` cycle a manual separator character
- `r` toggle raw value (supported widgets)
- `.` cycle precise/compact/whole number formatting (supported widgets)
- `j` toggle the compact label preset for labeled widgets (Model → `M:`, Context → `Ctx:`, Cost → `$`)
- `m` cycle merge mode (`off` → `merge` → `merge no padding`)
- `x` exclude the selected widget and the rest of its line from shared Powerline column widths (shown only when Powerline auto-alignment is enabled)
- `Esc` go back
Expand Down
19 changes: 19 additions & 0 deletions src/tui/components/GlobalOverridesMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors);
const [globalBold, setGlobalBold] = useState(settings.globalBold);
const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode);
const [compactLabels, setCompactLabels] = useState(settings.compactLabels);
const [numberFormatMode, setNumberFormatMode] = useState(false);
const [numberFormatKindIndex, setNumberFormatKindIndex] = useState(0);
const [gradientMode, setGradientMode] = useState(false);
Expand Down Expand Up @@ -259,6 +260,15 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
minimalistMode: newMinimalistMode
};
onUpdate(updatedSettings);
} else if (input === 'j' || input === 'J') {
// Toggle compact label presets for labeled widgets
const newCompactLabels = !compactLabels;
setCompactLabels(newCompactLabels);
const updatedSettings = {
...settings,
compactLabels: newCompactLabels
};
onUpdate(updatedSettings);
} else if (input === 'n' || input === 'N') {
setNumberFormatMode(true);
setNumberFormatKindIndex(0);
Expand Down Expand Up @@ -451,6 +461,12 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<Text dimColor> - Press (m) to toggle</Text>
</Box>

<Box>
<Text> Compact Labels: </Text>
<Text color={compactLabels ? 'green' : 'red'}>{compactLabels ? '✓ Enabled' : '✗ Disabled'}</Text>
<Text dimColor> - Press (j) to toggle</Text>
</Box>

<Box>
<Text>Number Formatting: </Text>
<Text color='cyan'>{settings.numberFormat ? 'customized' : '(defaults)'}</Text>
Expand Down Expand Up @@ -558,6 +574,9 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<Text dimColor wrap='wrap'>
• Minimalist Mode: Strips decorative prefixes and labels from widgets
</Text>
<Text dimColor wrap='wrap'>
• Compact Labels: Uses short label presets (M:, Ctx:, $) on labeled widgets
</Text>
<Text dimColor wrap='wrap'>
• Override colors: All widgets will use these colors instead of their configured colors
</Text>
Expand Down
23 changes: 22 additions & 1 deletion src/tui/components/ItemsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { canDetectTerminalWidth } from '../../utils/terminal';
import {
filterWidgetCatalog,
getCompactLabelCapableTypes,
getMatchSegments,
getWidget,
getWidgetCatalog,
Expand All @@ -31,6 +32,10 @@ import {
getHideKeybind,
getHideModifierText
} from '../../widgets/shared/hideable';
import {
getCompactLabelKeybind,
getCompactLabelModifierText
} from '../../widgets/shared/raw-or-labeled';

import { ConfirmDialog } from './ConfirmDialog';
import { HideStatesEditor } from './HideStatesEditor';
Expand Down Expand Up @@ -132,6 +137,12 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
keybinds.push(getHideKeybind());
}

// Labeled widgets whose label has a compact preset share a single
// compact-label toggle here, mirroring the (h)ide… keybind.
if (getCompactLabelCapableTypes().has(widget.type)) {
keybinds.push(getCompactLabelKeybind());
}

return keybinds;
};

Expand Down Expand Up @@ -259,7 +270,8 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
getUniqueBackgroundColor,
settings
});
});

Expand Down Expand Up @@ -582,6 +594,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
? getNumberFormatModifierText(widget)
: undefined;
const hideModifierText = widgetImpl ? getHideModifierText(widget, widgetImpl.getHideableStates?.() ?? []) : undefined;
const compactModifierText = widgetImpl && getCompactLabelCapableTypes().has(widget.type)
? getCompactLabelModifierText(widget, settings)
: undefined;

return (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
Expand Down Expand Up @@ -611,6 +626,12 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{hideModifierText}
</Text>
)}
{compactModifierText && (
<Text dimColor>
{' '}
{compactModifierText}
</Text>
)}
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged→)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
Expand Down
39 changes: 39 additions & 0 deletions src/tui/components/__tests__/GlobalOverridesMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,45 @@ describe('GlobalOverridesMenu', () => {
}
});

it('toggles compact labels on when (j) is pressed', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();

const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: { ...DEFAULT_SETTINGS, compactLabels: false },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);

try {
await flushInk();
expect(stdout.getOutput()).toContain('Compact Labels:');
stdin.write('j');
await flushInk();

expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ compactLabels: true }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});

it('toggles minimalist mode off when (m) is pressed while enabled', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
Expand Down
17 changes: 16 additions & 1 deletion src/tui/components/items-editor/input-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Settings } from '../../../types/Settings';
import type {
CustomKeybind,
Widget,
Expand All @@ -15,6 +16,10 @@ import {
type WidgetCatalogEntry
} from '../../../utils/widgets';
import { EDIT_HIDE_STATES_ACTION } from '../../../widgets/shared/hideable';
import {
TOGGLE_COMPACT_LABEL_ACTION,
toggleCompactLabel
} from '../../../widgets/shared/raw-or-labeled';

export type WidgetPickerAction = 'change' | 'add' | 'insert';
export type WidgetPickerLevel = 'category' | 'widget';
Expand Down Expand Up @@ -354,6 +359,7 @@ export interface HandleNormalInputModeArgs {
getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
getUniqueBackgroundColor?: (insertIndex: number) => string | undefined;
settings?: Settings;
}

export function handleNormalInputMode({
Expand All @@ -371,7 +377,8 @@ export function handleNormalInputMode({
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
getUniqueBackgroundColor,
settings
}: HandleNormalInputModeArgs): void {
if (key.upArrow && widgets.length > 0) {
setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1);
Expand Down Expand Up @@ -501,6 +508,14 @@ export function handleNormalInputMode({
const newWidgets = [...widgets];
newWidgets[selectedIndex] = cycleNumberStyle(currentWidget);
onUpdate(newWidgets);
} else if (matchedKeybind.action === TOGGLE_COMPACT_LABEL_ACTION) {
// The compact-label toggle is shared by every labeled
// widget, so like the precision cycle it is applied here.
// The settings let the cycle start from the effective
// (possibly globally inherited) state.
const newWidgets = [...widgets];
newWidgets[selectedIndex] = toggleCompactLabel(currentWidget, settings);
onUpdate(newWidgets);
} else if (widgetImpl.handleEditorAction) {
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
Expand Down
5 changes: 5 additions & 0 deletions src/types/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export const SettingsSchema = z.object({
overrideBackgroundColor: z.string().optional(),
overrideForegroundColor: z.string().optional(),
globalBold: z.boolean().default(false),
// Compact label presets (Model: -> M:, Context: -> Ctx:, Cost: -> $) for
// labeled widgets. Additive v4 key with an off default, so configs written
// before it render identically without a version bump; per-widget
// metadata.compactLabel overrides the global setting either way.
compactLabels: z.boolean().default(false),
numberFormat: GlobalNumberFormatSchema.optional(),
gitCacheTtlSeconds: z.number().min(0).max(60).default(5),
// How long a "no TTY" result is reused for the same session, in seconds.
Expand Down
107 changes: 107 additions & 0 deletions src/utils/__tests__/renderer-compact-labels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
describe,
expect,
it
} from 'vitest';

import type { RenderContext } from '../../types/RenderContext';
import {
DEFAULT_SETTINGS,
SettingsSchema,
SettingsSchema_v1,
type Settings
} from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { toggleCompactLabel } from '../../widgets/shared/raw-or-labeled';
import { stripSgrCodes } from '../ansi';
import { migrateConfig } from '../migrations';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from '../renderer';

function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
colorLevel: 0,
...overrides,
powerline: {
...DEFAULT_SETTINGS.powerline,
...(overrides.powerline ?? {})
}
};
}

const CONTEXT: RenderContext = { isPreview: false, terminalWidth: 200, data: { model: 'Opus 4.6', cost: { total_cost_usd: 2.456 } } };

function renderLine(widgets: WidgetItem[], settings: Settings): string {
const preRenderedLines = preRenderAllWidgets([widgets], settings, CONTEXT);
const maxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
return stripSgrCodes(renderStatusLine(widgets, settings, CONTEXT, preRenderedLines[0] ?? [], maxWidths));
}

const TRIO: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'separator', character: '|' },
{ id: '3', type: 'session-cost' }
];

describe('compact labels rendering', () => {
it('keeps default labels when the setting is absent (old configs)', () => {
expect(DEFAULT_SETTINGS.compactLabels).toBe(false);
expect(renderLine(TRIO, createSettings())).toBe('Model: Opus 4.6 | Cost: $2.46');
});

it('shortens mapped labels when compactLabels is on', () => {
expect(renderLine(TRIO, createSettings({ compactLabels: true }))).toBe('M: Opus 4.6 | $2.46');
});

it('lets a per-widget opt-out beat the global setting', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'separator', character: '|' },
{ id: '3', type: 'session-cost', metadata: { compactLabel: 'false' } }
];
expect(renderLine(widgets, createSettings({ compactLabels: true }))).toBe('M: Opus 4.6 | Cost: $2.46');
});

it('lets a per-widget opt-in work without the global setting', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model', metadata: { compactLabel: 'true' } },
{ id: '2', type: 'separator', character: '|' },
{ id: '3', type: 'session-cost' }
];
expect(renderLine(widgets, createSettings())).toBe('M: Opus 4.6 | Cost: $2.46');
});

it('the per-widget (j) toggle beats the global setting at render time', () => {
const settings = createSettings({ compactLabels: true });
// Simulate pressing (j) once on the model widget while the global
// flag is on: the cycle writes an explicit 'false' and the label
// renders in its default form.
const toggled = toggleCompactLabel({ id: '1', type: 'model' }, settings);
expect(toggled.metadata).toEqual({ compactLabel: 'false' });
// Model is forced back to its default label while session-cost stays
// compact through the global flag.
expect(renderLine([toggled, { id: '2', type: 'separator', character: '|' }, { id: '3', type: 'session-cost' }], settings))
.toBe('Model: Opus 4.6 | $2.46');
});

it('parses legacy settings without the new key to the off default', () => {
const legacy = SettingsSchema.parse({ version: 4, lines: [[{ id: '1', type: 'model' }]] });
expect(legacy.compactLabels).toBe(false);
});

it('migrates a v1 config and keeps compact labels off', () => {
const v1 = { lines: [[{ id: '1', type: 'model' }]], globalBold: false };
const migrated = migrateConfig(v1, 4) as Record<string, unknown>;
const parsed = SettingsSchema.parse(migrated);
expect(parsed.version).toBe(4);
expect(parsed.compactLabels).toBe(false);
});

it('keeps legacy v1 schema unaware of the new key', () => {
expect('compactLabels' in SettingsSchema_v1.shape).toBe(false);
});
});
Loading
Loading