diffstalker 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bun.lock +72 -312
  3. package/dist/App.js +1136 -515
  4. package/dist/core/ExplorerStateManager.js +266 -0
  5. package/dist/core/FilePathWatcher.js +133 -0
  6. package/dist/core/GitStateManager.js +75 -16
  7. package/dist/git/ignoreUtils.js +30 -0
  8. package/dist/git/status.js +2 -34
  9. package/dist/index.js +67 -53
  10. package/dist/ipc/CommandClient.js +165 -0
  11. package/dist/ipc/CommandServer.js +152 -0
  12. package/dist/state/CommitFlowState.js +86 -0
  13. package/dist/state/UIState.js +182 -0
  14. package/dist/types/tabs.js +4 -0
  15. package/dist/ui/Layout.js +252 -0
  16. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  17. package/dist/ui/modals/DiscardConfirm.js +77 -0
  18. package/dist/ui/modals/HotkeysModal.js +209 -0
  19. package/dist/ui/modals/ThemePicker.js +107 -0
  20. package/dist/ui/widgets/CommitPanel.js +58 -0
  21. package/dist/ui/widgets/CompareListView.js +216 -0
  22. package/dist/ui/widgets/DiffView.js +279 -0
  23. package/dist/ui/widgets/ExplorerContent.js +102 -0
  24. package/dist/ui/widgets/ExplorerView.js +95 -0
  25. package/dist/ui/widgets/FileList.js +185 -0
  26. package/dist/ui/widgets/Footer.js +46 -0
  27. package/dist/ui/widgets/Header.js +111 -0
  28. package/dist/ui/widgets/HistoryView.js +69 -0
  29. package/dist/utils/ansiToBlessed.js +125 -0
  30. package/dist/utils/displayRows.js +185 -6
  31. package/dist/utils/explorerDisplayRows.js +1 -1
  32. package/dist/utils/languageDetection.js +56 -0
  33. package/dist/utils/pathUtils.js +27 -0
  34. package/dist/utils/rowCalculations.js +37 -0
  35. package/dist/utils/wordDiff.js +50 -0
  36. package/package.json +11 -12
  37. package/dist/components/BaseBranchPicker.js +0 -60
  38. package/dist/components/BottomPane.js +0 -101
  39. package/dist/components/CommitPanel.js +0 -58
  40. package/dist/components/CompareListView.js +0 -110
  41. package/dist/components/ExplorerContentView.js +0 -80
  42. package/dist/components/ExplorerView.js +0 -37
  43. package/dist/components/FileList.js +0 -131
  44. package/dist/components/Footer.js +0 -6
  45. package/dist/components/Header.js +0 -107
  46. package/dist/components/HistoryView.js +0 -21
  47. package/dist/components/HotkeysModal.js +0 -108
  48. package/dist/components/Modal.js +0 -19
  49. package/dist/components/ScrollableList.js +0 -125
  50. package/dist/components/ThemePicker.js +0 -42
  51. package/dist/components/TopPane.js +0 -14
  52. package/dist/components/UnifiedDiffView.js +0 -115
  53. package/dist/hooks/useCommitFlow.js +0 -66
  54. package/dist/hooks/useCompareState.js +0 -123
  55. package/dist/hooks/useExplorerState.js +0 -248
  56. package/dist/hooks/useGit.js +0 -156
  57. package/dist/hooks/useHistoryState.js +0 -62
  58. package/dist/hooks/useKeymap.js +0 -167
  59. package/dist/hooks/useLayout.js +0 -154
  60. package/dist/hooks/useMouse.js +0 -87
  61. package/dist/hooks/useTerminalSize.js +0 -20
  62. package/dist/hooks/useWatcher.js +0 -137
@@ -0,0 +1,252 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * Layout constants matching the React/Ink implementation.
4
+ */
5
+ export const LAYOUT_OVERHEAD = 5; // Header (1-2) + 3 separators + footer (1)
6
+ export const SPLIT_RATIO_STEP = 0.05;
7
+ /**
8
+ * Calculate layout dimensions based on terminal size and split ratio.
9
+ */
10
+ export function calculateLayout(terminalHeight, terminalWidth, splitRatio, headerHeight = 1) {
11
+ // Total overhead: header + 3 separators + footer
12
+ const overhead = headerHeight + 4; // 3 separators + 1 footer
13
+ const availableHeight = terminalHeight - overhead;
14
+ const topPaneHeight = Math.floor(availableHeight * splitRatio);
15
+ const bottomPaneHeight = availableHeight - topPaneHeight;
16
+ return {
17
+ width: terminalWidth,
18
+ height: terminalHeight,
19
+ headerHeight,
20
+ topPaneHeight,
21
+ bottomPaneHeight,
22
+ footerRow: terminalHeight - 1,
23
+ };
24
+ }
25
+ /**
26
+ * Calculate pane boundaries for mouse click detection.
27
+ */
28
+ export function calculatePaneBoundaries(terminalHeight, headerHeight, topPaneHeight, bottomPaneHeight) {
29
+ const stagingPaneStart = headerHeight + 1; // After header + separator
30
+ const fileListEnd = stagingPaneStart + topPaneHeight;
31
+ const diffPaneStart = fileListEnd + 1; // After separator
32
+ const diffPaneEnd = diffPaneStart + bottomPaneHeight;
33
+ const footerRow = terminalHeight - 1;
34
+ return {
35
+ stagingPaneStart,
36
+ fileListEnd,
37
+ diffPaneStart,
38
+ diffPaneEnd,
39
+ footerRow,
40
+ };
41
+ }
42
+ /**
43
+ * LayoutManager creates and manages blessed boxes for the two-pane layout.
44
+ */
45
+ export class LayoutManager {
46
+ screen;
47
+ headerBox;
48
+ topSeparator;
49
+ topPane;
50
+ middleSeparator;
51
+ bottomPane;
52
+ bottomSeparator;
53
+ footerBox;
54
+ _dimensions;
55
+ _splitRatio;
56
+ constructor(screen, splitRatio = 0.4) {
57
+ this.screen = screen;
58
+ this._splitRatio = splitRatio;
59
+ this._dimensions = this.calculateDimensions();
60
+ // Create all layout boxes
61
+ this.headerBox = this.createHeaderBox();
62
+ this.topSeparator = this.createSeparator(this._dimensions.headerHeight);
63
+ this.topPane = this.createTopPane();
64
+ this.middleSeparator = this.createSeparator(this._dimensions.headerHeight + 1 + this._dimensions.topPaneHeight);
65
+ this.bottomPane = this.createBottomPane();
66
+ this.bottomSeparator = this.createSeparator(this._dimensions.headerHeight +
67
+ 2 +
68
+ this._dimensions.topPaneHeight +
69
+ this._dimensions.bottomPaneHeight);
70
+ this.footerBox = this.createFooterBox();
71
+ // Handle screen resize
72
+ screen.on('resize', () => this.onResize());
73
+ }
74
+ get dimensions() {
75
+ return this._dimensions;
76
+ }
77
+ get splitRatio() {
78
+ return this._splitRatio;
79
+ }
80
+ setSplitRatio(ratio) {
81
+ this._splitRatio = Math.min(0.85, Math.max(0.15, ratio));
82
+ this.updateLayout();
83
+ }
84
+ adjustSplitRatio(delta) {
85
+ this.setSplitRatio(this._splitRatio + delta);
86
+ }
87
+ calculateDimensions() {
88
+ const height = this.screen.height || 24;
89
+ const width = this.screen.width || 80;
90
+ return calculateLayout(height, width, this._splitRatio);
91
+ }
92
+ createHeaderBox() {
93
+ return blessed.box({
94
+ parent: this.screen,
95
+ top: 0,
96
+ left: 0,
97
+ width: '100%',
98
+ height: this._dimensions.headerHeight,
99
+ tags: true,
100
+ });
101
+ }
102
+ createSeparator(top) {
103
+ const width = this.screen.width || 80;
104
+ return blessed.box({
105
+ parent: this.screen,
106
+ top,
107
+ left: 0,
108
+ width: '100%',
109
+ height: 1,
110
+ content: '\u2500'.repeat(width),
111
+ style: {
112
+ fg: 'gray',
113
+ },
114
+ });
115
+ }
116
+ createTopPane() {
117
+ return blessed.box({
118
+ parent: this.screen,
119
+ top: this._dimensions.headerHeight + 1,
120
+ left: 0,
121
+ width: '100%',
122
+ height: this._dimensions.topPaneHeight,
123
+ tags: true,
124
+ scrollable: true,
125
+ alwaysScroll: true,
126
+ wrap: false, // Disable blessed's built-in wrapping - we handle wrapping ourselves
127
+ scrollbar: {
128
+ ch: ' ',
129
+ track: {
130
+ bg: 'gray',
131
+ },
132
+ style: {
133
+ inverse: true,
134
+ },
135
+ },
136
+ });
137
+ }
138
+ createBottomPane() {
139
+ return blessed.box({
140
+ parent: this.screen,
141
+ top: this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight,
142
+ left: 0,
143
+ width: '100%',
144
+ height: this._dimensions.bottomPaneHeight,
145
+ tags: true,
146
+ scrollable: true,
147
+ alwaysScroll: true,
148
+ wrap: false, // Disable blessed's built-in wrapping - we handle wrapping ourselves
149
+ scrollbar: {
150
+ ch: ' ',
151
+ track: {
152
+ bg: 'gray',
153
+ },
154
+ style: {
155
+ inverse: true,
156
+ },
157
+ },
158
+ });
159
+ }
160
+ createFooterBox() {
161
+ return blessed.box({
162
+ parent: this.screen,
163
+ top: this._dimensions.footerRow,
164
+ left: 0,
165
+ width: '100%',
166
+ height: 1,
167
+ tags: true,
168
+ });
169
+ }
170
+ onResize() {
171
+ this._dimensions = this.calculateDimensions();
172
+ this.updateLayout();
173
+ // Don't call screen.render() here - App's resize handler will render
174
+ // with properly recalculated content
175
+ }
176
+ updateLayout() {
177
+ this._dimensions = this.calculateDimensions();
178
+ const width = this.screen.width || 80;
179
+ // Update header
180
+ this.headerBox.height = this._dimensions.headerHeight;
181
+ this.headerBox.width = width;
182
+ // Update top separator
183
+ this.topSeparator.top = this._dimensions.headerHeight;
184
+ this.topSeparator.width = width;
185
+ this.topSeparator.setContent('\u2500'.repeat(width));
186
+ // Update top pane
187
+ this.topPane.top = this._dimensions.headerHeight + 1;
188
+ this.topPane.height = this._dimensions.topPaneHeight;
189
+ this.topPane.width = width;
190
+ // Update middle separator
191
+ this.middleSeparator.top = this._dimensions.headerHeight + 1 + this._dimensions.topPaneHeight;
192
+ this.middleSeparator.width = width;
193
+ this.middleSeparator.setContent('\u2500'.repeat(width));
194
+ // Update bottom pane
195
+ this.bottomPane.top = this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight;
196
+ this.bottomPane.height = this._dimensions.bottomPaneHeight;
197
+ this.bottomPane.width = width;
198
+ // Update bottom separator
199
+ this.bottomSeparator.top =
200
+ this._dimensions.headerHeight +
201
+ 2 +
202
+ this._dimensions.topPaneHeight +
203
+ this._dimensions.bottomPaneHeight;
204
+ this.bottomSeparator.width = width;
205
+ this.bottomSeparator.setContent('\u2500'.repeat(width));
206
+ // Update footer
207
+ this.footerBox.top = this._dimensions.footerRow;
208
+ this.footerBox.width = width;
209
+ }
210
+ /**
211
+ * Get pane boundaries for mouse click detection.
212
+ */
213
+ getPaneBoundaries() {
214
+ return calculatePaneBoundaries(this._dimensions.height, this._dimensions.headerHeight, this._dimensions.topPaneHeight, this._dimensions.bottomPaneHeight);
215
+ }
216
+ /**
217
+ * Convert screen Y coordinate to content row within the top pane.
218
+ * Returns the 0-based row index of the content, or -1 if outside the pane.
219
+ */
220
+ screenYToTopPaneRow(screenY) {
221
+ const paneTop = this._dimensions.headerHeight + 1; // header + separator
222
+ const paneBottom = paneTop + this._dimensions.topPaneHeight;
223
+ if (screenY < paneTop || screenY >= paneBottom) {
224
+ return -1;
225
+ }
226
+ return screenY - paneTop;
227
+ }
228
+ /**
229
+ * Convert screen Y coordinate to content row within the bottom pane.
230
+ * Returns the 0-based row index of the content, or -1 if outside the pane.
231
+ */
232
+ screenYToBottomPaneRow(screenY) {
233
+ const paneTop = this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight; // header + 2 separators + top pane
234
+ const paneBottom = paneTop + this._dimensions.bottomPaneHeight;
235
+ if (screenY < paneTop || screenY >= paneBottom) {
236
+ return -1;
237
+ }
238
+ return screenY - paneTop;
239
+ }
240
+ /**
241
+ * Get the top position of the top pane (for reference).
242
+ */
243
+ get topPaneTop() {
244
+ return this._dimensions.headerHeight + 1;
245
+ }
246
+ /**
247
+ * Get the top position of the bottom pane (for reference).
248
+ */
249
+ get bottomPaneTop() {
250
+ return this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight;
251
+ }
252
+ }
@@ -0,0 +1,110 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * BaseBranchPicker modal for selecting the base branch for PR comparison.
4
+ */
5
+ export class BaseBranchPicker {
6
+ box;
7
+ screen;
8
+ branches;
9
+ selectedIndex;
10
+ currentBranch;
11
+ onSelect;
12
+ onCancel;
13
+ constructor(screen, branches, currentBranch, onSelect, onCancel) {
14
+ this.screen = screen;
15
+ this.branches = branches;
16
+ this.currentBranch = currentBranch;
17
+ this.onSelect = onSelect;
18
+ this.onCancel = onCancel;
19
+ // Find current branch index
20
+ this.selectedIndex = currentBranch ? branches.indexOf(currentBranch) : 0;
21
+ if (this.selectedIndex < 0)
22
+ this.selectedIndex = 0;
23
+ // Create modal box
24
+ const width = 50;
25
+ const maxVisibleBranches = Math.min(branches.length, 15);
26
+ const height = maxVisibleBranches + 6; // branches + header + footer + borders + padding
27
+ this.box = blessed.box({
28
+ parent: screen,
29
+ top: 'center',
30
+ left: 'center',
31
+ width,
32
+ height,
33
+ border: {
34
+ type: 'line',
35
+ },
36
+ style: {
37
+ border: {
38
+ fg: 'cyan',
39
+ },
40
+ },
41
+ tags: true,
42
+ keys: true,
43
+ scrollable: true,
44
+ alwaysScroll: true,
45
+ });
46
+ // Setup key handlers
47
+ this.setupKeyHandlers();
48
+ // Initial render
49
+ this.render();
50
+ }
51
+ setupKeyHandlers() {
52
+ this.box.key(['escape', 'q'], () => {
53
+ this.close();
54
+ this.onCancel();
55
+ });
56
+ this.box.key(['enter', 'space'], () => {
57
+ const selected = this.branches[this.selectedIndex];
58
+ if (selected) {
59
+ this.close();
60
+ this.onSelect(selected);
61
+ }
62
+ });
63
+ this.box.key(['up', 'k'], () => {
64
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
65
+ this.render();
66
+ });
67
+ this.box.key(['down', 'j'], () => {
68
+ this.selectedIndex = Math.min(this.branches.length - 1, this.selectedIndex + 1);
69
+ this.render();
70
+ });
71
+ }
72
+ render() {
73
+ const lines = [];
74
+ // Header
75
+ lines.push('{bold}{cyan-fg} Select Base Branch{/cyan-fg}{/bold}');
76
+ lines.push('');
77
+ if (this.branches.length === 0) {
78
+ lines.push('{gray-fg}No branches found{/gray-fg}');
79
+ }
80
+ else {
81
+ // Branch list
82
+ for (let i = 0; i < this.branches.length; i++) {
83
+ const branch = this.branches[i];
84
+ const isSelected = i === this.selectedIndex;
85
+ const isCurrent = branch === this.currentBranch;
86
+ let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
87
+ line += branch;
88
+ if (isSelected)
89
+ line += '{/bold}{/cyan-fg}';
90
+ if (isCurrent)
91
+ line += ' {gray-fg}(current){/gray-fg}';
92
+ lines.push(line);
93
+ }
94
+ }
95
+ // Footer
96
+ lines.push('');
97
+ lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
98
+ this.box.setContent(lines.join('\n'));
99
+ this.screen.render();
100
+ }
101
+ close() {
102
+ this.box.destroy();
103
+ }
104
+ /**
105
+ * Focus the modal.
106
+ */
107
+ focus() {
108
+ this.box.focus();
109
+ }
110
+ }
@@ -0,0 +1,77 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * DiscardConfirm modal for confirming discard of file changes.
4
+ */
5
+ export class DiscardConfirm {
6
+ box;
7
+ screen;
8
+ filePath;
9
+ onConfirm;
10
+ onCancel;
11
+ constructor(screen, filePath, onConfirm, onCancel) {
12
+ this.screen = screen;
13
+ this.filePath = filePath;
14
+ this.onConfirm = onConfirm;
15
+ this.onCancel = onCancel;
16
+ // Create modal box - small confirmation dialog
17
+ const width = Math.min(60, Math.max(40, filePath.length + 20));
18
+ const height = 7;
19
+ this.box = blessed.box({
20
+ parent: screen,
21
+ top: 'center',
22
+ left: 'center',
23
+ width,
24
+ height,
25
+ border: {
26
+ type: 'line',
27
+ },
28
+ style: {
29
+ border: {
30
+ fg: 'yellow',
31
+ },
32
+ },
33
+ tags: true,
34
+ keys: true,
35
+ });
36
+ // Setup key handlers
37
+ this.setupKeyHandlers();
38
+ // Render content
39
+ this.render();
40
+ }
41
+ setupKeyHandlers() {
42
+ this.box.key(['y', 'Y'], () => {
43
+ this.close();
44
+ this.onConfirm();
45
+ });
46
+ this.box.key(['n', 'N', 'escape', 'q'], () => {
47
+ this.close();
48
+ this.onCancel();
49
+ });
50
+ }
51
+ render() {
52
+ const lines = [];
53
+ // Header
54
+ lines.push('{bold}{yellow-fg} Discard Changes?{/yellow-fg}{/bold}');
55
+ lines.push('');
56
+ // File path (truncate if needed)
57
+ const maxPathLen = this.box.width - 6;
58
+ const displayPath = this.filePath.length > maxPathLen
59
+ ? '...' + this.filePath.slice(-(maxPathLen - 3))
60
+ : this.filePath;
61
+ lines.push(`{white-fg}${displayPath}{/white-fg}`);
62
+ lines.push('');
63
+ // Prompt
64
+ lines.push('{gray-fg}Press {/gray-fg}{green-fg}y{/green-fg}{gray-fg} to confirm, {/gray-fg}{red-fg}n{/red-fg}{gray-fg} or Esc to cancel{/gray-fg}');
65
+ this.box.setContent(lines.join('\n'));
66
+ this.screen.render();
67
+ }
68
+ close() {
69
+ this.box.destroy();
70
+ }
71
+ /**
72
+ * Focus the modal.
73
+ */
74
+ focus() {
75
+ this.box.focus();
76
+ }
77
+ }
@@ -0,0 +1,209 @@
1
+ import blessed from 'neo-blessed';
2
+ const hotkeyGroups = [
3
+ {
4
+ title: 'Navigation',
5
+ entries: [
6
+ { key: 'j/k', description: 'Move up/down' },
7
+ { key: 'Tab', description: 'Toggle pane focus' },
8
+ ],
9
+ },
10
+ {
11
+ title: 'Staging',
12
+ entries: [
13
+ { key: 's', description: 'Stage file' },
14
+ { key: 'U', description: 'Unstage file' },
15
+ { key: 'A', description: 'Stage all' },
16
+ { key: 'Z', description: 'Unstage all' },
17
+ { key: 'Space', description: 'Toggle stage' },
18
+ ],
19
+ },
20
+ {
21
+ title: 'Actions',
22
+ entries: [
23
+ { key: 'c', description: 'Commit panel' },
24
+ { key: 'r', description: 'Refresh' },
25
+ { key: 'q', description: 'Quit' },
26
+ ],
27
+ },
28
+ {
29
+ title: 'Resize',
30
+ entries: [
31
+ { key: '-', description: 'Shrink top pane' },
32
+ { key: '+', description: 'Grow top pane' },
33
+ ],
34
+ },
35
+ {
36
+ title: 'Tabs',
37
+ entries: [
38
+ { key: '1', description: 'Diff view' },
39
+ { key: '2', description: 'Commit panel' },
40
+ { key: '3', description: 'History view' },
41
+ { key: '4', description: 'Compare view' },
42
+ { key: '5', description: 'Explorer view' },
43
+ ],
44
+ },
45
+ {
46
+ title: 'Toggles',
47
+ entries: [
48
+ { key: 'm', description: 'Mouse mode' },
49
+ { key: 'w', description: 'Wrap mode' },
50
+ { key: 'f', description: 'Follow mode' },
51
+ { key: 't', description: 'Theme picker' },
52
+ { key: '?', description: 'This help' },
53
+ ],
54
+ },
55
+ {
56
+ title: 'Explorer',
57
+ entries: [
58
+ { key: 'Enter', description: 'Enter directory' },
59
+ { key: 'Backspace', description: 'Go up' },
60
+ ],
61
+ },
62
+ {
63
+ title: 'Compare',
64
+ entries: [
65
+ { key: 'b', description: 'Base branch picker' },
66
+ { key: 'u', description: 'Toggle uncommitted' },
67
+ ],
68
+ },
69
+ {
70
+ title: 'Diff',
71
+ entries: [{ key: 'd', description: 'Discard changes' }],
72
+ },
73
+ ];
74
+ /**
75
+ * HotkeysModal shows available keyboard shortcuts.
76
+ */
77
+ export class HotkeysModal {
78
+ box;
79
+ screen;
80
+ onClose;
81
+ constructor(screen, onClose) {
82
+ this.screen = screen;
83
+ this.onClose = onClose;
84
+ // Calculate modal dimensions
85
+ const screenWidth = screen.width;
86
+ const screenHeight = screen.height;
87
+ // Determine layout based on screen width
88
+ const useTwoColumns = screenWidth >= 90;
89
+ const width = useTwoColumns ? Math.min(80, screenWidth - 4) : Math.min(42, screenWidth - 4);
90
+ const height = Math.min(this.calculateHeight(useTwoColumns), screenHeight - 4);
91
+ this.box = blessed.box({
92
+ parent: screen,
93
+ top: 'center',
94
+ left: 'center',
95
+ width,
96
+ height,
97
+ border: {
98
+ type: 'line',
99
+ },
100
+ style: {
101
+ border: {
102
+ fg: 'cyan',
103
+ },
104
+ },
105
+ tags: true,
106
+ keys: true,
107
+ scrollable: true,
108
+ alwaysScroll: true,
109
+ });
110
+ // Setup key handlers
111
+ this.setupKeyHandlers();
112
+ // Render content
113
+ this.render(useTwoColumns, width);
114
+ }
115
+ calculateHeight(useTwoColumns) {
116
+ if (useTwoColumns) {
117
+ const midpoint = Math.ceil(hotkeyGroups.length / 2);
118
+ const leftGroups = hotkeyGroups.slice(0, midpoint);
119
+ const rightGroups = hotkeyGroups.slice(midpoint);
120
+ const leftLines = leftGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
121
+ const rightLines = rightGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
122
+ return Math.max(leftLines, rightLines) + 5;
123
+ }
124
+ else {
125
+ return hotkeyGroups.reduce((sum, g) => sum + g.entries.length + 2, 0) + 5;
126
+ }
127
+ }
128
+ setupKeyHandlers() {
129
+ this.box.key(['escape', 'enter', '?', 'q'], () => {
130
+ this.close();
131
+ this.onClose();
132
+ });
133
+ // Close on click anywhere
134
+ this.box.on('click', () => {
135
+ this.close();
136
+ this.onClose();
137
+ });
138
+ }
139
+ /**
140
+ * Calculate the visible width of a string (excluding blessed tags).
141
+ */
142
+ visibleWidth(str) {
143
+ return str.replace(/\{[^}]+\}/g, '').length;
144
+ }
145
+ /**
146
+ * Pad a string with blessed tags to a visible width.
147
+ */
148
+ padToVisible(str, targetWidth) {
149
+ const visible = this.visibleWidth(str);
150
+ const padding = Math.max(0, targetWidth - visible);
151
+ return str + ' '.repeat(padding);
152
+ }
153
+ render(useTwoColumns, width) {
154
+ const lines = [];
155
+ // Header
156
+ lines.push('{bold}{cyan-fg} Keyboard Shortcuts{/cyan-fg}{/bold}');
157
+ lines.push('');
158
+ if (useTwoColumns) {
159
+ const midpoint = Math.ceil(hotkeyGroups.length / 2);
160
+ const leftGroups = hotkeyGroups.slice(0, midpoint);
161
+ const rightGroups = hotkeyGroups.slice(midpoint);
162
+ const colWidth = Math.floor((width - 6) / 2);
163
+ // Render side by side
164
+ const leftLines = this.renderGroups(leftGroups, colWidth);
165
+ const rightLines = this.renderGroups(rightGroups, colWidth);
166
+ const maxLines = Math.max(leftLines.length, rightLines.length);
167
+ for (let i = 0; i < maxLines; i++) {
168
+ const left = this.padToVisible(leftLines[i] || '', colWidth);
169
+ const right = rightLines[i] || '';
170
+ lines.push(left + ' ' + right);
171
+ }
172
+ }
173
+ else {
174
+ // Single column
175
+ for (const group of hotkeyGroups) {
176
+ lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
177
+ for (const entry of group.entries) {
178
+ lines.push(` {cyan-fg}${entry.key.padEnd(10)}{/cyan-fg} ${entry.description}`);
179
+ }
180
+ lines.push('');
181
+ }
182
+ }
183
+ // Footer
184
+ lines.push('');
185
+ lines.push('{gray-fg}Press Esc, Enter, or ? to close{/gray-fg}');
186
+ this.box.setContent(lines.join('\n'));
187
+ this.screen.render();
188
+ }
189
+ renderGroups(groups, colWidth) {
190
+ const lines = [];
191
+ for (const group of groups) {
192
+ lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
193
+ for (const entry of group.entries) {
194
+ lines.push(` {cyan-fg}${entry.key.padEnd(10)}{/cyan-fg} ${entry.description}`);
195
+ }
196
+ lines.push('');
197
+ }
198
+ return lines;
199
+ }
200
+ close() {
201
+ this.box.destroy();
202
+ }
203
+ /**
204
+ * Focus the modal.
205
+ */
206
+ focus() {
207
+ this.box.focus();
208
+ }
209
+ }