Skip to content

Commit 8ae6481

Browse files
zachthedevclaudesirmalloc
authored
feat: allow overriding widget symbols with a custom glyph (#431)
* feat: allow overriding widget symbols with a custom glyph Extends the item.character override (already honored by Git Staged/Unstaged/Untracked) to every widget with a hardcoded symbol and makes it editable in the TUI. - Add src/widgets/shared/symbol-override.tsx: shared (g)lyph keybind, symbol/prefix helpers, and a slot-based editor (type a character or emoji, Backspace renders without a symbol, choosing the default clears the override); single-symbol widgets store on item.character, multi-symbol widgets keep per-part metadata keys - Wire it into Git Branch (⎇), Git Worktree (𖠰), Git Worktree Mode (⎇), Git Conflicts (⚠), JJ Bookmarks (🔖), JJ Workspace (◆), the three presence widgets that already supported item.character, and the multi-symbol widgets Git Ahead/Behind (↑/↓) and Git Status (!+*?) - Add preview-path render coverage for default/override/suppressed plus helper unit tests; document the keybind in docs/USAGE.md Co-authored-by: Claude <noreply@anthropic.com> * fix: align glyph editor labels --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
1 parent 887f786 commit 8ae6481

15 files changed

Lines changed: 683 additions & 43 deletions

docs/USAGE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ The keybind footer in the TUI only shows shortcuts that apply to the currently s
176176

177177
Widget-specific shortcuts:
178178
- **Git widgets with empty-state toggles**: `h` hide `no git` / empty output where supported
179+
- **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
179180
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted)
180181
- **Git Root Dir**: `l` cycle IDE links (`off``VS Code``Cursor`)
181182
- **Git PR**: `h` hide empty/no-PR/MR output, `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins)

src/widgets/GitAheadBehind.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CustomKeybind,
55
Widget,
66
WidgetEditorDisplay,
7+
WidgetEditorProps,
78
WidgetItem
89
} from '../types/Widget';
910
import {
@@ -18,6 +19,15 @@ import {
1819
handleToggleNoGitAction,
1920
isHideNoGitEnabled
2021
} from './shared/git-no-git';
22+
import {
23+
getSlotSymbol,
24+
getSymbolKeybind,
25+
renderSymbolSlotsEditor,
26+
type SymbolSlot
27+
} from './shared/symbol-override';
28+
29+
const AHEAD_SLOT: SymbolSlot = { id: 'symbolAhead', label: 'Ahead', defaultSymbol: '↑' };
30+
const BEHIND_SLOT: SymbolSlot = { id: 'symbolBehind', label: 'Behind', defaultSymbol: '↓' };
2131

2232
export class GitAheadBehindWidget implements Widget {
2333
getDefaultColor(): string { return 'cyan'; }
@@ -43,11 +53,13 @@ export class GitAheadBehindWidget implements Widget {
4353

4454
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
4555
const hideNoGit = isHideNoGitEnabled(item);
56+
const aheadSymbol = getSlotSymbol(item, AHEAD_SLOT);
57+
const behindSymbol = getSlotSymbol(item, BEHIND_SLOT);
4658

4759
if (context.isPreview) {
4860
if (item.rawValue)
4961
return '2,3';
50-
return '↑2↓3';
62+
return `${aheadSymbol}2${behindSymbol}3`;
5163
}
5264

5365
if (!isInsideGitWorkTree(context)) {
@@ -70,15 +82,22 @@ export class GitAheadBehindWidget implements Widget {
7082

7183
const parts: string[] = [];
7284
if (result.ahead > 0)
73-
parts.push(`${result.ahead}`);
85+
parts.push(`${aheadSymbol}${result.ahead}`);
7486
if (result.behind > 0)
75-
parts.push(`${result.behind}`);
87+
parts.push(`${behindSymbol}${result.behind}`);
7688

7789
return parts.join('');
7890
}
7991

8092
getCustomKeybinds(): CustomKeybind[] {
81-
return getHideNoGitKeybinds();
93+
return [
94+
...getHideNoGitKeybinds(),
95+
getSymbolKeybind()
96+
];
97+
}
98+
99+
renderEditor(props: WidgetEditorProps) {
100+
return renderSymbolSlotsEditor(props, [AHEAD_SLOT, BEHIND_SLOT]);
82101
}
83102

84103
getNumericValue(context: RenderContext, _item: WidgetItem): number | null {

src/widgets/GitBranch.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CustomKeybind,
55
Widget,
66
WidgetEditorDisplay,
7+
WidgetEditorProps,
78
WidgetItem
89
} from '../types/Widget';
910
import {
@@ -27,7 +28,13 @@ import {
2728
isHideNoGitEnabled
2829
} from './shared/git-no-git';
2930
import { isMetadataFlagEnabled } from './shared/metadata';
31+
import {
32+
formatSymbolPrefix,
33+
getSymbolKeybind,
34+
renderSymbolOverrideEditor
35+
} from './shared/symbol-override';
3036

37+
const DEFAULT_SYMBOL = '⎇';
3138
const LINK_KEY = 'linkToRepo';
3239
const LEGACY_LINK_KEY = 'linkToGitHub';
3340
const TOGGLE_LINK_ACTION = 'toggle-link';
@@ -88,22 +95,23 @@ export class GitBranchWidget implements Widget {
8895
void settings;
8996
const hideNoGit = isHideNoGitEnabled(item);
9097
const isLink = isLinkEnabled(item);
98+
const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL);
9199

92100
if (context.isPreview) {
93-
const text = item.rawValue ? 'main' : '⎇ main';
101+
const text = item.rawValue ? 'main' : `${prefix}main`;
94102
return isLink ? renderOsc8Link('https://github.com/owner/repo/tree/main', text) : text;
95103
}
96104

97105
if (!isInsideGitWorkTree(context)) {
98-
return hideNoGit ? null : '⎇ no git';
106+
return hideNoGit ? null : `${prefix}no git`;
99107
}
100108

101109
const branch = this.getGitBranch(context);
102110
if (!branch) {
103-
return hideNoGit ? null : '⎇ no git';
111+
return hideNoGit ? null : `${prefix}no git`;
104112
}
105113

106-
const displayText = item.rawValue ? branch : `${branch}`;
114+
const displayText = item.rawValue ? branch : `${prefix}${branch}`;
107115

108116
if (isLink) {
109117
const origin = getRemoteInfo('origin', context);
@@ -125,10 +133,15 @@ export class GitBranchWidget implements Widget {
125133
getCustomKeybinds(): CustomKeybind[] {
126134
return [
127135
...getHideNoGitKeybinds(),
128-
{ key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION }
136+
{ key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION },
137+
getSymbolKeybind()
129138
];
130139
}
131140

141+
renderEditor(props: WidgetEditorProps) {
142+
return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL);
143+
}
144+
132145
supportsRawValue(): boolean { return true; }
133146
supportsColors(item: WidgetItem): boolean { return true; }
134147
}

src/widgets/GitConflicts.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CustomKeybind,
55
Widget,
66
WidgetEditorDisplay,
7+
WidgetEditorProps,
78
WidgetItem
89
} from '../types/Widget';
910
import {
@@ -18,6 +19,13 @@ import {
1819
handleToggleNoGitAction,
1920
isHideNoGitEnabled
2021
} from './shared/git-no-git';
22+
import {
23+
formatSymbolPrefix,
24+
getSymbolKeybind,
25+
renderSymbolOverrideEditor
26+
} from './shared/symbol-override';
27+
28+
const DEFAULT_SYMBOL = '⚠';
2129

2230
export class GitConflictsWidget implements Widget {
2331
getDefaultColor(): string { return 'red'; }
@@ -43,11 +51,12 @@ export class GitConflictsWidget implements Widget {
4351

4452
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
4553
const hideNoGit = isHideNoGitEnabled(item);
54+
const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL);
4655

4756
if (context.isPreview) {
4857
if (item.rawValue)
4958
return '2';
50-
return '⚠ 2';
59+
return `${prefix}2`;
5160
}
5261

5362
if (!isInsideGitWorkTree(context)) {
@@ -60,11 +69,18 @@ export class GitConflictsWidget implements Widget {
6069
return count.toString();
6170
}
6271

63-
return `${count}`;
72+
return `${prefix}${count}`;
6473
}
6574

6675
getCustomKeybinds(): CustomKeybind[] {
67-
return getHideNoGitKeybinds();
76+
return [
77+
...getHideNoGitKeybinds(),
78+
getSymbolKeybind()
79+
];
80+
}
81+
82+
renderEditor(props: WidgetEditorProps) {
83+
return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL);
6884
}
6985

7086
getNumericValue(context: RenderContext, _item: WidgetItem): number | null {

src/widgets/GitStaged.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CustomKeybind,
55
Widget,
66
WidgetEditorDisplay,
7+
WidgetEditorProps,
78
WidgetItem
89
} from '../types/Widget';
910
import {
@@ -18,6 +19,11 @@ import {
1819
handleToggleNoGitAction,
1920
isHideNoGitEnabled
2021
} from './shared/git-no-git';
22+
import {
23+
getSymbol,
24+
getSymbolKeybind,
25+
renderSymbolOverrideEditor
26+
} from './shared/symbol-override';
2127

2228
const DEFAULT_SYMBOL = '+';
2329

@@ -47,7 +53,7 @@ export class GitStagedWidget implements Widget {
4753
const hideNoGit = isHideNoGitEnabled(item);
4854

4955
if (context.isPreview) {
50-
return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL);
56+
return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL);
5157
}
5258

5359
if (!isInsideGitWorkTree(context)) {
@@ -60,11 +66,18 @@ export class GitStagedWidget implements Widget {
6066
return null;
6167
}
6268

63-
return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL);
69+
return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL);
6470
}
6571

6672
getCustomKeybinds(): CustomKeybind[] {
67-
return getHideNoGitKeybinds();
73+
return [
74+
...getHideNoGitKeybinds(),
75+
getSymbolKeybind()
76+
];
77+
}
78+
79+
renderEditor(props: WidgetEditorProps) {
80+
return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL);
6881
}
6982

7083
getNumericValue(context: RenderContext, _item: WidgetItem): number | null {

src/widgets/GitStatus.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CustomKeybind,
55
Widget,
66
WidgetEditorDisplay,
7+
WidgetEditorProps,
78
WidgetItem
89
} from '../types/Widget';
910
import {
@@ -18,6 +19,17 @@ import {
1819
handleToggleNoGitAction,
1920
isHideNoGitEnabled
2021
} from './shared/git-no-git';
22+
import {
23+
getSlotSymbol,
24+
getSymbolKeybind,
25+
renderSymbolSlotsEditor,
26+
type SymbolSlot
27+
} from './shared/symbol-override';
28+
29+
const CONFLICTS_SLOT: SymbolSlot = { id: 'symbolConflicts', label: 'Conflicts', defaultSymbol: '!' };
30+
const STAGED_SLOT: SymbolSlot = { id: 'symbolStaged', label: 'Staged', defaultSymbol: '+' };
31+
const UNSTAGED_SLOT: SymbolSlot = { id: 'symbolUnstaged', label: 'Unstaged', defaultSymbol: '*' };
32+
const UNTRACKED_SLOT: SymbolSlot = { id: 'symbolUntracked', label: 'Untracked', defaultSymbol: '?' };
2133

2234
export class GitStatusWidget implements Widget {
2335
getDefaultColor(): string { return 'yellow'; }
@@ -62,22 +74,29 @@ export class GitStatusWidget implements Widget {
6274
return this.formatStatus(item, status);
6375
}
6476

65-
private formatStatus(_item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean; conflicts: boolean }): string {
77+
private formatStatus(item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean; conflicts: boolean }): string {
6678
const parts: string[] = [];
6779
if (status.conflicts)
68-
parts.push('!');
80+
parts.push(getSlotSymbol(item, CONFLICTS_SLOT));
6981
if (status.staged)
70-
parts.push('+');
82+
parts.push(getSlotSymbol(item, STAGED_SLOT));
7183
if (status.unstaged)
72-
parts.push('*');
84+
parts.push(getSlotSymbol(item, UNSTAGED_SLOT));
7385
if (status.untracked)
74-
parts.push('?');
86+
parts.push(getSlotSymbol(item, UNTRACKED_SLOT));
7587

7688
return parts.join('');
7789
}
7890

7991
getCustomKeybinds(): CustomKeybind[] {
80-
return getHideNoGitKeybinds();
92+
return [
93+
...getHideNoGitKeybinds(),
94+
getSymbolKeybind()
95+
];
96+
}
97+
98+
renderEditor(props: WidgetEditorProps) {
99+
return renderSymbolSlotsEditor(props, [CONFLICTS_SLOT, STAGED_SLOT, UNSTAGED_SLOT, UNTRACKED_SLOT]);
81100
}
82101

83102
supportsRawValue(): boolean { return false; }

src/widgets/GitUnstaged.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CustomKeybind,
55
Widget,
66
WidgetEditorDisplay,
7+
WidgetEditorProps,
78
WidgetItem
89
} from '../types/Widget';
910
import {
@@ -18,6 +19,11 @@ import {
1819
handleToggleNoGitAction,
1920
isHideNoGitEnabled
2021
} from './shared/git-no-git';
22+
import {
23+
getSymbol,
24+
getSymbolKeybind,
25+
renderSymbolOverrideEditor
26+
} from './shared/symbol-override';
2127

2228
const DEFAULT_SYMBOL = '*';
2329

@@ -47,7 +53,7 @@ export class GitUnstagedWidget implements Widget {
4753
const hideNoGit = isHideNoGitEnabled(item);
4854

4955
if (context.isPreview) {
50-
return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL);
56+
return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL);
5157
}
5258

5359
if (!isInsideGitWorkTree(context)) {
@@ -60,11 +66,18 @@ export class GitUnstagedWidget implements Widget {
6066
return null;
6167
}
6268

63-
return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL);
69+
return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL);
6470
}
6571

6672
getCustomKeybinds(): CustomKeybind[] {
67-
return getHideNoGitKeybinds();
73+
return [
74+
...getHideNoGitKeybinds(),
75+
getSymbolKeybind()
76+
];
77+
}
78+
79+
renderEditor(props: WidgetEditorProps) {
80+
return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL);
6881
}
6982

7083
getNumericValue(context: RenderContext, _item: WidgetItem): number | null {

0 commit comments

Comments
 (0)