Skip to content

Commit cdd1fe5

Browse files
feat: auto-group macros in the sidebar
Add optional sidebar grouping derived from macro names, with settings for camelCase splitting, nesting depth, and minimum group size. Grouping is off by default and only affects sidebar display, not stored macro names. Closes #1845 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bea7175 commit cdd1fe5

19 files changed

Lines changed: 690 additions & 20 deletions

packages/uhk-common/src/models/application-settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MacroGroupingSettings } from './macro-grouping-settings.js';
12
import { RgbColorInterface } from './rgb-color-interface.js';
23

34
export enum AppTheme {
@@ -34,4 +35,8 @@ export interface ApplicationSettings {
3435
* If true, the Advanced settings menu is shown on Agent startup.
3536
*/
3637
alwaysEnableAdvancedMode?: boolean;
38+
/**
39+
* Sidebar macro grouping preferences.
40+
*/
41+
macroGrouping?: Partial<MacroGroupingSettings>;
3742
}

packages/uhk-common/src/models/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './application-settings.js';
2+
export * from './macro-grouping-settings.js';
23
export * from './backup-user-configuration.js';
34
export * from './backup-user-configuration-info.js';
45
export * from './ble-address-pair.js';
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
export interface MacroGroupingSettings {
2+
camelCaseSeparation: boolean;
3+
enabled: boolean;
4+
maxDepth: number;
5+
minChildren: number;
6+
}
7+
8+
export const MACRO_GROUPING_MIN_DEPTH = 1;
9+
10+
// Mirrors side-menu.component.scss ($side-menu-width, $macro-tree-base-indent, $macro-tree-indent-step).
11+
const MACRO_TREE_SIDE_MENU_WIDTH_PX = 250;
12+
const MACRO_TREE_LEVEL1_PADDING_PX = 24;
13+
const MACRO_TREE_BASE_INDENT_PX = 32;
14+
const MACRO_TREE_INDENT_STEP_PX = 12;
15+
const MACRO_TREE_RESERVED_RIGHT_PX = 48;
16+
const MACRO_TREE_MIN_LABEL_WIDTH_PX = 72;
17+
18+
/**
19+
* Deepest macro-tree depth the sidebar layout can fit before labels get too narrow.
20+
* The CSS indent is unbounded; this limit comes from the 250px sidebar width.
21+
*/
22+
export const MACRO_GROUPING_MAX_DEPTH = Math.max(
23+
MACRO_GROUPING_MIN_DEPTH,
24+
Math.floor(
25+
(MACRO_TREE_SIDE_MENU_WIDTH_PX
26+
- MACRO_TREE_LEVEL1_PADDING_PX
27+
- MACRO_TREE_BASE_INDENT_PX
28+
- MACRO_TREE_RESERVED_RIGHT_PX
29+
- MACRO_TREE_MIN_LABEL_WIDTH_PX)
30+
/ MACRO_TREE_INDENT_STEP_PX
31+
)
32+
);
33+
34+
export const DEFAULT_MACRO_GROUPING_SETTINGS: MacroGroupingSettings = {
35+
camelCaseSeparation: false,
36+
enabled: false,
37+
maxDepth: 1,
38+
minChildren: 2,
39+
};
40+
41+
export function normalizeMacroGroupingSettings(
42+
settings?: Partial<MacroGroupingSettings>
43+
): MacroGroupingSettings {
44+
const merged: MacroGroupingSettings = {
45+
...DEFAULT_MACRO_GROUPING_SETTINGS,
46+
...settings,
47+
};
48+
49+
return {
50+
...merged,
51+
maxDepth: Math.min(
52+
MACRO_GROUPING_MAX_DEPTH,
53+
Math.max(MACRO_GROUPING_MIN_DEPTH, merged.maxDepth)
54+
),
55+
minChildren: Math.min(10, Math.max(2, merged.minChildren)),
56+
};
57+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it } from 'node:test';
2+
3+
import {
4+
DEFAULT_MACRO_GROUPING_SETTINGS,
5+
MACRO_GROUPING_MAX_DEPTH,
6+
normalizeMacroGroupingSettings,
7+
} from '../models/macro-grouping-settings.js';
8+
import { groupMacrosByName, GroupableMacroItem, splitMacroName } from './group-macros-by-name.js';
9+
10+
function createMacro(id: number, name: string): GroupableMacroItem {
11+
return {
12+
id,
13+
name
14+
};
15+
}
16+
17+
const ENABLED_MACRO_GROUPING_SETTINGS = {
18+
...DEFAULT_MACRO_GROUPING_SETTINGS,
19+
enabled: true,
20+
};
21+
22+
describe('splitMacroName', () => {
23+
it('splits on non-alphanumeric separators', ({ assert }) => {
24+
assert.deepEqual(splitMacroName('Doom: Chainsaw', false), ['Doom', 'Chainsaw']);
25+
assert.deepEqual(splitMacroName('Doom_Plasma gun', false), ['Doom', 'Plasma', 'gun']);
26+
});
27+
28+
it('optionally splits camel case segments', ({ assert }) => {
29+
assert.deepEqual(splitMacroName('bindMouseMacros', true), ['bind', 'Mouse', 'Macros']);
30+
});
31+
32+
it('does not split on dollar signs used in smart macro names', ({ assert }) => {
33+
assert.deepEqual(splitMacroName('$onInit', true), ['$on', 'Init']);
34+
assert.deepEqual(splitMacroName('$onInit', false), ['$onInit']);
35+
});
36+
});
37+
38+
describe('groupMacrosByName', () => {
39+
it('returns a flat list when grouping is disabled', ({ assert }) => {
40+
const macros = [
41+
createMacro(1, 'Doom: Chainsaw'),
42+
createMacro(2, 'Doom: Plasma gun')
43+
];
44+
45+
const result = groupMacrosByName(macros, {
46+
...DEFAULT_MACRO_GROUPING_SETTINGS,
47+
enabled: false
48+
});
49+
50+
assert.equal(result.length, 2);
51+
assert.equal(result[0].type, 'macro');
52+
assert.equal(result[1].type, 'macro');
53+
});
54+
55+
it('groups macros that share a prefix separated by non-alphanumeric characters', ({ assert }) => {
56+
const macros = [
57+
createMacro(1, 'Doom: Chainsaw'),
58+
createMacro(2, 'Doom: Plasma gun'),
59+
createMacro(3, 'Brightness up')
60+
];
61+
62+
const result = groupMacrosByName(macros, ENABLED_MACRO_GROUPING_SETTINGS);
63+
64+
assert.equal(result.length, 2);
65+
assert.deepEqual(result.map(node => node.type), ['macro', 'group']);
66+
assert.equal(result[0].macro?.name, 'Brightness up');
67+
assert.equal(result[1].label, 'Doom');
68+
assert.equal(result[1].children?.length, 2);
69+
assert.equal(result[1].children?.[0].displayName, 'Chainsaw');
70+
assert.equal(result[1].children?.[1].displayName, 'Plasma gun');
71+
});
72+
73+
it('does not create a group when there are fewer macros than minChildren', ({ assert }) => {
74+
const macros = [
75+
createMacro(1, 'Doom: Chainsaw'),
76+
createMacro(2, 'Brightness up')
77+
];
78+
79+
const result = groupMacrosByName(macros, ENABLED_MACRO_GROUPING_SETTINGS);
80+
81+
assert.equal(result.length, 2);
82+
assert.deepEqual(result.map(node => node.type), ['macro', 'macro']);
83+
assert.equal(result[0].macro?.name, 'Brightness up');
84+
assert.equal(result[1].macro?.name, 'Doom: Chainsaw');
85+
});
86+
87+
it('groups by camel case when enabled', ({ assert }) => {
88+
const macros = [
89+
createMacro(1, 'bindMouseLeft'),
90+
createMacro(2, 'bindMouseRight'),
91+
createMacro(3, 'brightnessUp')
92+
];
93+
94+
const result = groupMacrosByName(macros, {
95+
...ENABLED_MACRO_GROUPING_SETTINGS,
96+
camelCaseSeparation: true
97+
});
98+
99+
assert.equal(result.length, 2);
100+
assert.deepEqual(result.map(node => node.type), ['group', 'macro']);
101+
assert.equal(result[0].label, 'bind');
102+
assert.equal(result[0].children?.length, 2);
103+
assert.deepEqual(result[0].children?.map(child => child.displayName), ['MouseLeft', 'MouseRight']);
104+
assert.equal(result[1].macro?.name, 'brightnessUp');
105+
});
106+
107+
it('groups dollar-prefixed smart macro names under the dollar prefix', ({ assert }) => {
108+
const macros = [
109+
createMacro(1, '$onInit'),
110+
createMacro(2, '$onJoin'),
111+
];
112+
113+
const result = groupMacrosByName(macros, {
114+
...ENABLED_MACRO_GROUPING_SETTINGS,
115+
camelCaseSeparation: true,
116+
});
117+
118+
assert.equal(result.length, 1);
119+
assert.equal(result[0].type, 'group');
120+
assert.equal(result[0].label, '$on');
121+
assert.deepEqual(result[0].children?.map(child => child.displayName), ['Init', 'Join']);
122+
});
123+
124+
it('keeps parent-level remainders when deeper nesting does not apply', ({ assert }) => {
125+
const macros = [
126+
createMacro(1, 'Open daily sites'),
127+
createMacro(2, 'Open weekly sites'),
128+
];
129+
130+
const result = groupMacrosByName(macros, {
131+
...ENABLED_MACRO_GROUPING_SETTINGS,
132+
maxDepth: 2,
133+
});
134+
135+
assert.equal(result.length, 1);
136+
assert.equal(result[0].label, 'Open');
137+
assert.deepEqual(result[0].children?.map(child => child.displayName), ['daily sites', 'weekly sites']);
138+
});
139+
});
140+
141+
describe('normalizeMacroGroupingSettings', () => {
142+
it('clamps maxDepth to the supported sidebar layout limit', ({ assert }) => {
143+
const result = normalizeMacroGroupingSettings({ maxDepth: 99 });
144+
145+
assert.equal(result.maxDepth, MACRO_GROUPING_MAX_DEPTH);
146+
});
147+
});

0 commit comments

Comments
 (0)