Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ All widgets must implement:
- `render()`: Core rendering logic that produces the widget output
- `supportsRawValue()`: Whether widget supports raw value mode
- `supportsColors()`: Whether widget supports color customization
- Optional: `renderEditor()`, `getCustomKeybinds()`, `handleEditorAction()`
- Optional: `renderEditor()`, `getCustomKeybinds()`, `getHideableStates()`, `handleEditorAction()`

**Widget Registry Pattern:**
- Located in src/utils/widgets.ts
Expand Down
50 changes: 43 additions & 7 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,31 +192,67 @@ Widget picker:
The keybind footer in the TUI only shows shortcuts that apply to the currently selected widget.

Widget-specific shortcuts:
- **Git widgets with empty-state toggles**: `h` hide `no git` / empty output where supported
- **Glyph widgets** (Git Branch, Git Worktree, Git Worktree Mode, Git Staged, Git Unstaged, Git Untracked, Git Conflicts, Git Ahead/Behind, Git Status, JJ Bookmarks, JJ Workspace): `g` set custom glyphs for the widget's symbols; Backspace in the editor renders without one, and multi-symbol widgets (Ahead/Behind, Status) edit each part in one list
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted)
- **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`)
- **Git PR**: `h` hide empty/no-PR/MR output, `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins)
- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `h` hide when no remote, `l` toggle clickable repo links
- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `l` toggle clickable repo links
- **Git Origin Owner/Repo**: `o` show only the owner when the repo is a fork
- **Git Is Fork**: `h` hide when the repo is not a fork
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode, `t` toggle the time cursor in bar modes
- **Block Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
- **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle hours-only in time mode or 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
- **Context Bar**: `p` cycle medium/full/short/short-only progress bar
- **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
- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope, `h` hide when empty
- **Compaction Counter**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed
- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope
- **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
- **Skills**: `v` cycle view mode, `h` hide when empty, `l` edit list limit in list mode
- **Skills**: `v` cycle view mode, `l` edit list limit in list mode
- **Input Speed / Output Speed / Total Speed**: `w` edit the rolling window in seconds
- **Custom Text / Custom Symbol**: `e` edit text or symbol
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
- **Link**: `u` URL, `e` link text
- **Vim Mode**: `f` cycle format, `n` toggle Nerd Font icons

## Hiding Widgets Conditionally

Widgets that can hide themselves share a single `h` (`(h)ide…`) keybind in the
line editor. It opens a checklist of the hideable states the selected widget
supports; toggle entries with `Space` and confirm with `Enter`. Enabled states
appear in the editor as `(hide: no-git, zero)`.

Supported states by widget family:
- **Git widgets**: `no-git` hides the `(no git)` placeholder outside a repo
- **Git count widgets** (`Git Changes`, `Git Insertions`, `Git Deletions`, `Git Staged/Unstaged/Untracked Files`, `Git Conflicts`): `zero` hides the widget while its count is zero
- **JJ widgets**: `no-jj` hides the `(no jj)` placeholder outside a jj repo
- **Git Origin widgets**: `no-remote` hides the `no remote` placeholder
- **Git Upstream widgets / Git Ahead/Behind**: `no-upstream` hides the `no upstream` placeholder
- **Git Ahead/Behind**: `zero` hides `↑0↓0` when the branch is not diverged (enabled by default; uncheck it to show zeros)
- **Git PR** (rendered as "MR" for GitLab origins): `no-data` hides the `(no PR)` placeholder, `status` and `title` hide those segments of the PR display
- **Git Is Fork**: `not-fork` hides the widget when the repo is not a fork
- **Token widgets**: `zero` hides `0` token counts at session start
- **Session Cost**: `zero` hides `$0.00`
- **Session Clock**: `zero` hides durations under one minute
- **Block Timer**: `no-data` hides the `0hr 0m` / empty-bar display when no block is active
- **Input/Output/Total Speed**: `no-data` hides the `—` placeholder when no speed data exists
- **Output Style**: `default-value` hides the widget when the style is `default`
- **Compaction Counter**: `zero` hides the counter before any compaction occurs
- **Skills**: `empty` hides the widget before any skill is used
- **Extra Usage widgets**: `disabled` hides the `n/a` display when extra usage is off, `no-data` hides the error placeholder when usage data is unavailable
- **Session / Weekly / Weekly Sonnet / Weekly Opus Usage**: `no-data` hides the error placeholder (`[No credentials]`, `[Timeout]`, `[Rate limited]`, `[API Error]`, `[Parse Error]`) when usage data is unavailable
- **Custom Text / Custom Symbol**: `merge-target-hidden` hides the item when the widget it is merged with renders nothing, so icon prefixes/suffixes disappear together with their widget

In `settings.json`, the enabled states are stored as a comma-separated list in
the item's metadata, e.g. `"metadata": { "hide": "no-git,zero" }`. An empty
list (`"hide": ""`) opts out of default-enabled states such as Git
Ahead/Behind's `zero`. Configs from older versions that used per-widget
boolean keys (`hideNoGit`, `hideNoJj`, `hideNoRemote`, `hideZero`,
`hideWhenEmpty`, `hideIfDisabled`, `hideStatus`, `hideTitle`,
`hideWhenNotFork`) are converted to the unified key automatically by the
settings migration on first load. After that, downgrading to an older
ccstatusline keeps the config readable, but older versions ignore the `hide`
key, so previously hidden placeholders show again until you upgrade back.

## Custom Widgets

### Custom Text Widget
Expand Down
75 changes: 75 additions & 0 deletions src/tui/components/HideStatesEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';

import type {
HideableState,
WidgetItem
} from '../../types/Widget';
import {
MERGE_TARGET_HIDDEN_HIDEABLE_STATE,
getEnabledHideStates,
setEnabledHideStates
} from '../../widgets/shared/hideable';

export interface HideStatesEditorProps {
widget: WidgetItem;
states: HideableState[];
onComplete: (updatedWidget: WidgetItem) => void;
onCancel: () => void;
}

export const HideStatesEditor: React.FC<HideStatesEditorProps> = ({ widget, states, onComplete, onCancel }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [enabledKeys, setEnabledKeys] = useState<string[]>(() => getEnabledHideStates(widget, states));

useInput((input, key) => {
if (key.return) {
onComplete(setEnabledHideStates(widget, states, enabledKeys));
} else if (key.escape) {
onCancel();
} else if (key.upArrow && states.length > 0) {
setSelectedIndex(selectedIndex - 1 < 0 ? states.length - 1 : selectedIndex - 1);
} else if (key.downArrow && states.length > 0) {
setSelectedIndex(selectedIndex + 1 > states.length - 1 ? 0 : selectedIndex + 1);
} else if (input === ' ') {
const state = states[selectedIndex];
if (state) {
setEnabledKeys(enabledKeys.includes(state.key)
? enabledKeys.filter(enabledKey => enabledKey !== state.key)
: [...enabledKeys, state.key]);
}
}
});

return (
<Box flexDirection='column'>
<Text bold>Hide</Text>
<Text dimColor>↑↓ select, Space toggle, Enter save, ESC cancel</Text>
<Box marginTop={1} flexDirection='column'>
{states.map((state, index) => {
const isSelected = index === selectedIndex;
const isEnabled = enabledKeys.includes(state.key);
return (
<Box key={state.key} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`[${isEnabled ? 'x' : ' '}] ${state.label}`}
</Text>
{state.key === MERGE_TARGET_HIDDEN_HIDEABLE_STATE.key && !widget.merge && (
<Text dimColor> (requires merge)</Text>
)}
</Box>
);
})}
</Box>
</Box>
);
};
39 changes: 36 additions & 3 deletions src/tui/components/ItemsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ import {
getWidgetCatalog,
getWidgetCatalogCategories
} from '../../utils/widgets';
import {
EDIT_HIDE_STATES_ACTION,
getHideKeybind,
getHideModifierText
} from '../../widgets/shared/hideable';

import { ConfirmDialog } from './ConfirmDialog';
import { HideStatesEditor } from './HideStatesEditor';
import {
handleMoveInputMode,
handleNormalInputMode,
Expand Down Expand Up @@ -96,11 +102,18 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
};

const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
if (!widgetImpl.getCustomKeybinds) {
return [];
const keybinds = widgetImpl.getCustomKeybinds ? [...widgetImpl.getCustomKeybinds(widget)] : [];

// Widgets declaring hideable states share a single (h)ide… keybind
// that opens the hide-state checklist instead of per-widget toggles.
// Such widgets must leave 'h' unbound: keybind matching takes the
// first hit, so a widget-level 'h' would shadow this one (enforced by
// a registry-wide test in utils/__tests__/widgets.test.ts)
if ((widgetImpl.getHideableStates?.().length ?? 0) > 0) {
keybinds.push(getHideKeybind());
}

return widgetImpl.getCustomKeybinds(widget);
return keybinds;
};

const openWidgetPicker = (action: WidgetPickerAction) => {
Expand Down Expand Up @@ -299,6 +312,19 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
? 'Insert Widget'
: 'Change Widget Type';

// The hide-state checklist is shared across all widgets that declare
// hideable states, so it renders here rather than via widget renderEditor
if (customEditorWidget?.action === EDIT_HIDE_STATES_ACTION) {
return (
<HideStatesEditor
widget={customEditorWidget.widget}
states={customEditorWidget.impl.getHideableStates?.() ?? []}
onComplete={handleEditorComplete}
onCancel={handleEditorCancel}
/>
);
}

// If custom editor is active, render it instead of the normal UI
if (customEditorWidget?.impl.renderEditor) {
return customEditorWidget.impl.renderEditor({
Expand Down Expand Up @@ -527,6 +553,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
const supportsRawValue = widgetImpl?.supportsRawValue() ?? false;
const hideModifierText = widgetImpl ? getHideModifierText(widget, widgetImpl.getHideableStates?.() ?? []) : undefined;

return (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
Expand All @@ -544,6 +571,12 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{modifierText}
</Text>
)}
{hideModifierText && (
<Text dimColor>
{' '}
{hideModifierText}
</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
1 change: 1 addition & 0 deletions src/tui/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export * from './ColorMenu';
export * from './ConfirmDialog';
export * from './GlobalOverridesMenu';
export * from './HideStatesEditor';
export * from './InstallMenu';
export * from './ItemsEditor';
export * from './LineSelector';
Expand Down
38 changes: 38 additions & 0 deletions src/tui/components/items-editor/__tests__/input-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {

import type { WidgetItem } from '../../../../types/Widget';
import type { WidgetCatalogEntry } from '../../../../utils/widgets';
import {
EDIT_HIDE_STATES_ACTION,
getHideKeybind
} from '../../../../widgets/shared/hideable';
import {
handleMoveInputMode,
handleNormalInputMode,
Expand Down Expand Up @@ -698,6 +702,40 @@ describe('items-editor input handlers', () => {
expect(updated?.[0]?.metadata?.mode).toBe('count');
});

it('opens the shared hide-states editor for the injected hide keybind', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'git-branch' }
];
const onUpdate = vi.fn();
const setCustomEditorWidget = vi.fn();

handleNormalInputMode({
input: 'h',
key: {},
widgets,
selectedIndex: 0,
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) : []),
getHideKeybind()
],
setCustomEditorWidget
});

expect(onUpdate).not.toHaveBeenCalled();
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
| { action?: string; widget?: WidgetItem }
| undefined;
expect(customEditorState?.action).toBe(EDIT_HIDE_STATES_ACTION);
expect(customEditorState?.widget?.type).toBe('git-branch');
});

it('opens custom editor for skills list limit action', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'skills', metadata: { mode: 'list' } }
Expand Down
11 changes: 10 additions & 1 deletion src/tui/components/items-editor/input-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getWidget,
type WidgetCatalogEntry
} from '../../../utils/widgets';
import { EDIT_HIDE_STATES_ACTION } from '../../../widgets/shared/hideable';

export type WidgetPickerAction = 'change' | 'add' | 'insert';
export type WidgetPickerLevel = 'category' | 'widget';
Expand Down Expand Up @@ -461,14 +462,22 @@ export function handleNormalInputMode({
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.getCustomKeybinds) {
if (!widgetImpl) {
return;
}

const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);

if (matchedKeybind && !key.ctrl) {
// The hide-state checklist is rendered by the items editor for
// every widget that declares hideable states, so it bypasses
// widget-level action handling
if (matchedKeybind.action === EDIT_HIDE_STATES_ACTION) {
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
return;
}

if (widgetImpl.handleEditorAction) {
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
Expand Down
2 changes: 1 addition & 1 deletion src/types/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { PowerlineConfigSchema } from './PowerlineConfig';
import { WidgetItemSchema } from './Widget';

// Current version - bump this when making breaking changes to the schema
export const CURRENT_VERSION = 3;
export const CURRENT_VERSION = 4;

export const InstallationMetadataSchema = z.discriminatedUnion('method', [
z.object({
Expand Down
11 changes: 10 additions & 1 deletion src/types/Widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const WidgetItemSchema = z.object({
preserveColors: z.boolean().optional(),
timeout: z.number().optional(),
merge: z.union([z.boolean(), z.literal('no-padding')]).optional(),
hide: z.boolean().optional(),
metadata: z.record(z.string(), z.string()).optional()
});

Expand All @@ -33,6 +32,15 @@ export interface WidgetEditorDisplay {
modifierText?: string;
}

// A condition under which a widget can hide instead of rendering placeholder
// output (e.g. 'no-git', 'zero'). Stored in metadata.hide as a comma-separated
// list of enabled state keys; defaultEnabled states apply when metadata.hide is absent.
export interface HideableState {
key: string;
label: string;
defaultEnabled?: boolean;
}

export interface Widget {
getDefaultColor(): string;
getDescription(): string;
Expand All @@ -41,6 +49,7 @@ export interface Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay;
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null;
getCustomKeybinds?(item?: WidgetItem): CustomKeybind[];
getHideableStates?(): HideableState[];
renderEditor?(props: WidgetEditorProps): React.ReactElement | null;
supportsRawValue(): boolean;
supportsColors(item: WidgetItem): boolean;
Expand Down
Loading