diffstalker 0.1.7 → 0.2.1

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 (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
@@ -0,0 +1,232 @@
1
+ import blessed from 'neo-blessed';
2
+ const MAX_RESULTS = 15;
3
+ /**
4
+ * Simple fuzzy match scoring.
5
+ * Returns -1 if no match, otherwise a score (higher is better).
6
+ */
7
+ function fuzzyScore(query, target) {
8
+ const lowerQuery = query.toLowerCase();
9
+ const lowerTarget = target.toLowerCase();
10
+ // Must contain all query characters in order
11
+ let queryIndex = 0;
12
+ let score = 0;
13
+ let lastMatchIndex = -1;
14
+ for (let i = 0; i < lowerTarget.length && queryIndex < lowerQuery.length; i++) {
15
+ if (lowerTarget[i] === lowerQuery[queryIndex]) {
16
+ // Bonus for consecutive matches
17
+ if (lastMatchIndex === i - 1) {
18
+ score += 10;
19
+ }
20
+ // Bonus for matching at start of word
21
+ if (i === 0 || lowerTarget[i - 1] === '/' || lowerTarget[i - 1] === '.') {
22
+ score += 5;
23
+ }
24
+ score += 1;
25
+ lastMatchIndex = i;
26
+ queryIndex++;
27
+ }
28
+ }
29
+ // All query characters must match
30
+ if (queryIndex < lowerQuery.length) {
31
+ return -1;
32
+ }
33
+ // Bonus for shorter paths (more specific)
34
+ score += Math.max(0, 50 - target.length);
35
+ return score;
36
+ }
37
+ /**
38
+ * Highlight matched characters in path.
39
+ */
40
+ function highlightMatch(query, path) {
41
+ if (!query)
42
+ return path;
43
+ const lowerQuery = query.toLowerCase();
44
+ const lowerPath = path.toLowerCase();
45
+ let result = '';
46
+ let queryIndex = 0;
47
+ for (let i = 0; i < path.length; i++) {
48
+ if (queryIndex < lowerQuery.length && lowerPath[i] === lowerQuery[queryIndex]) {
49
+ result += `{yellow-fg}${path[i]}{/yellow-fg}`;
50
+ queryIndex++;
51
+ }
52
+ else {
53
+ result += path[i];
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+ /**
59
+ * FileFinder modal for fuzzy file search.
60
+ */
61
+ export class FileFinder {
62
+ box;
63
+ textbox;
64
+ screen;
65
+ allPaths;
66
+ results = [];
67
+ selectedIndex = 0;
68
+ query = '';
69
+ onSelect;
70
+ onCancel;
71
+ constructor(screen, allPaths, onSelect, onCancel) {
72
+ this.screen = screen;
73
+ this.allPaths = allPaths;
74
+ this.onSelect = onSelect;
75
+ this.onCancel = onCancel;
76
+ // Create modal box
77
+ const width = Math.min(80, screen.width - 10);
78
+ const height = MAX_RESULTS + 6; // results + input + header + borders + padding
79
+ this.box = blessed.box({
80
+ parent: screen,
81
+ top: 'center',
82
+ left: 'center',
83
+ width,
84
+ height,
85
+ border: {
86
+ type: 'line',
87
+ },
88
+ style: {
89
+ border: {
90
+ fg: 'cyan',
91
+ },
92
+ },
93
+ tags: true,
94
+ keys: false, // We'll handle keys ourselves
95
+ });
96
+ // Create text input
97
+ this.textbox = blessed.textarea({
98
+ parent: this.box,
99
+ top: 1,
100
+ left: 1,
101
+ width: width - 4,
102
+ height: 1,
103
+ inputOnFocus: true,
104
+ style: {
105
+ fg: 'white',
106
+ bg: 'default',
107
+ },
108
+ });
109
+ // Setup key handlers
110
+ this.setupKeyHandlers();
111
+ // Initial render with all files
112
+ this.updateResults();
113
+ this.render();
114
+ }
115
+ setupKeyHandlers() {
116
+ // Handle escape to cancel
117
+ this.textbox.key(['escape'], () => {
118
+ this.close();
119
+ this.onCancel();
120
+ });
121
+ // Handle enter to select
122
+ this.textbox.key(['enter'], () => {
123
+ if (this.results.length > 0) {
124
+ const selected = this.results[this.selectedIndex];
125
+ this.close();
126
+ this.onSelect(selected.path);
127
+ }
128
+ });
129
+ // Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
130
+ this.textbox.key(['C-j', 'down'], () => {
131
+ this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
132
+ this.render();
133
+ });
134
+ this.textbox.key(['C-k', 'up'], () => {
135
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
136
+ this.render();
137
+ });
138
+ // Handle tab for next result
139
+ this.textbox.key(['tab'], () => {
140
+ this.selectedIndex = (this.selectedIndex + 1) % Math.max(1, this.results.length);
141
+ this.render();
142
+ });
143
+ // Handle shift-tab for previous result
144
+ this.textbox.key(['S-tab'], () => {
145
+ this.selectedIndex =
146
+ (this.selectedIndex - 1 + this.results.length) % Math.max(1, this.results.length);
147
+ this.render();
148
+ });
149
+ // Update results on keypress
150
+ this.textbox.on('keypress', () => {
151
+ // Defer to next tick to get updated value
152
+ setImmediate(() => {
153
+ const newQuery = this.textbox.getValue() || '';
154
+ if (newQuery !== this.query) {
155
+ this.query = newQuery;
156
+ this.selectedIndex = 0;
157
+ this.updateResults();
158
+ this.render();
159
+ }
160
+ });
161
+ });
162
+ }
163
+ updateResults() {
164
+ if (!this.query) {
165
+ // Show first N files when no query
166
+ this.results = this.allPaths.slice(0, MAX_RESULTS).map((path) => ({ path, score: 0 }));
167
+ return;
168
+ }
169
+ // Fuzzy match all paths
170
+ const scored = [];
171
+ for (const path of this.allPaths) {
172
+ const score = fuzzyScore(this.query, path);
173
+ if (score >= 0) {
174
+ scored.push({ path, score });
175
+ }
176
+ }
177
+ // Sort by score (descending)
178
+ scored.sort((a, b) => b.score - a.score);
179
+ // Take top results
180
+ this.results = scored.slice(0, MAX_RESULTS);
181
+ }
182
+ render() {
183
+ const lines = [];
184
+ const width = this.box.width - 4;
185
+ // Header
186
+ lines.push('{bold}{cyan-fg}Find File{/cyan-fg}{/bold}');
187
+ lines.push(''); // Space for input
188
+ lines.push('');
189
+ // Results
190
+ if (this.results.length === 0 && this.query) {
191
+ lines.push('{gray-fg}No matches{/gray-fg}');
192
+ }
193
+ else {
194
+ for (let i = 0; i < this.results.length; i++) {
195
+ const result = this.results[i];
196
+ const isSelected = i === this.selectedIndex;
197
+ // Truncate path if needed
198
+ let displayPath = result.path;
199
+ const maxLen = width - 4;
200
+ if (displayPath.length > maxLen) {
201
+ displayPath = '…' + displayPath.slice(-(maxLen - 1));
202
+ }
203
+ // Highlight matched characters
204
+ const highlighted = highlightMatch(this.query, displayPath);
205
+ if (isSelected) {
206
+ lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
207
+ }
208
+ else {
209
+ lines.push(` ${highlighted}`);
210
+ }
211
+ }
212
+ }
213
+ // Pad to fill space
214
+ while (lines.length < MAX_RESULTS + 3) {
215
+ lines.push('');
216
+ }
217
+ // Footer
218
+ lines.push('{gray-fg}Enter: select | Esc: cancel | Ctrl+j/k or ↑↓: navigate{/gray-fg}');
219
+ this.box.setContent(lines.join('\n'));
220
+ this.screen.render();
221
+ }
222
+ close() {
223
+ this.textbox.destroy();
224
+ this.box.destroy();
225
+ }
226
+ /**
227
+ * Focus the modal input.
228
+ */
229
+ focus() {
230
+ this.textbox.focus();
231
+ }
232
+ }
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ import blessed from 'neo-blessed';
2
+ import { themes, themeOrder, getTheme } from '../../themes.js';
3
+ /**
4
+ * ThemePicker modal for selecting diff themes.
5
+ */
6
+ export class ThemePicker {
7
+ box;
8
+ screen;
9
+ selectedIndex;
10
+ currentTheme;
11
+ onSelect;
12
+ onCancel;
13
+ constructor(screen, currentTheme, onSelect, onCancel) {
14
+ this.screen = screen;
15
+ this.currentTheme = currentTheme;
16
+ this.onSelect = onSelect;
17
+ this.onCancel = onCancel;
18
+ // Find current theme index
19
+ this.selectedIndex = themeOrder.indexOf(currentTheme);
20
+ if (this.selectedIndex < 0)
21
+ this.selectedIndex = 0;
22
+ // Create modal box
23
+ const width = 50;
24
+ const height = themeOrder.length + 12; // themes + header + preview + footer + borders + padding
25
+ this.box = blessed.box({
26
+ parent: screen,
27
+ top: 'center',
28
+ left: 'center',
29
+ width,
30
+ height,
31
+ border: {
32
+ type: 'line',
33
+ },
34
+ style: {
35
+ border: {
36
+ fg: 'cyan',
37
+ },
38
+ },
39
+ tags: true,
40
+ keys: true,
41
+ });
42
+ // Setup key handlers
43
+ this.setupKeyHandlers();
44
+ // Initial render
45
+ this.render();
46
+ }
47
+ setupKeyHandlers() {
48
+ this.box.key(['escape', 'q'], () => {
49
+ this.close();
50
+ this.onCancel();
51
+ });
52
+ this.box.key(['enter', 'space'], () => {
53
+ const selected = themeOrder[this.selectedIndex];
54
+ this.close();
55
+ this.onSelect(selected);
56
+ });
57
+ this.box.key(['up', 'k'], () => {
58
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
59
+ this.render();
60
+ });
61
+ this.box.key(['down', 'j'], () => {
62
+ this.selectedIndex = Math.min(themeOrder.length - 1, this.selectedIndex + 1);
63
+ this.render();
64
+ });
65
+ }
66
+ render() {
67
+ const lines = [];
68
+ // Header
69
+ lines.push('{bold}{cyan-fg} Select Theme{/cyan-fg}{/bold}');
70
+ lines.push('');
71
+ // Theme list
72
+ for (let i = 0; i < themeOrder.length; i++) {
73
+ const themeName = themeOrder[i];
74
+ const theme = themes[themeName];
75
+ const isSelected = i === this.selectedIndex;
76
+ const isCurrent = themeName === this.currentTheme;
77
+ let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
78
+ line += theme.displayName;
79
+ if (isSelected)
80
+ line += '{/bold}{/cyan-fg}';
81
+ if (isCurrent)
82
+ line += ' {gray-fg}(current){/gray-fg}';
83
+ lines.push(line);
84
+ }
85
+ // Preview section
86
+ lines.push('');
87
+ lines.push('{gray-fg}Preview:{/gray-fg}');
88
+ const previewTheme = getTheme(themeOrder[this.selectedIndex]);
89
+ // Simple preview - just show add/del colors
90
+ lines.push(` {green-fg}+ added line{/green-fg}`);
91
+ lines.push(` {red-fg}- deleted line{/red-fg}`);
92
+ // Footer
93
+ lines.push('');
94
+ lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
95
+ this.box.setContent(lines.join('\n'));
96
+ this.screen.render();
97
+ }
98
+ close() {
99
+ this.box.destroy();
100
+ }
101
+ /**
102
+ * Focus the modal.
103
+ */
104
+ focus() {
105
+ this.box.focus();
106
+ }
107
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Format the commit panel as blessed-compatible tagged string.
3
+ */
4
+ export function formatCommitPanel(state, stagedCount, width) {
5
+ const lines = [];
6
+ // Title
7
+ let title = '{bold}Commit Message{/bold}';
8
+ if (state.amend) {
9
+ title += ' {yellow-fg}(amending){/yellow-fg}';
10
+ }
11
+ lines.push(title);
12
+ lines.push('');
13
+ // Message input area
14
+ const borderChar = state.inputFocused ? '\u2502' : '\u2502';
15
+ const borderColor = state.inputFocused ? 'cyan' : 'gray';
16
+ // Top border
17
+ const innerWidth = Math.max(20, width - 6);
18
+ lines.push(`{${borderColor}-fg}\u250c${'─'.repeat(innerWidth + 2)}\u2510{/${borderColor}-fg}`);
19
+ // Message content (or placeholder)
20
+ const displayMessage = state.message || (state.inputFocused ? '' : 'Press i or Enter to edit...');
21
+ const messageColor = state.message ? '' : '{gray-fg}';
22
+ const messageEnd = state.message ? '' : '{/gray-fg}';
23
+ // Truncate message if needed
24
+ const truncatedMessage = displayMessage.length > innerWidth
25
+ ? displayMessage.slice(0, innerWidth - 1) + '\u2026'
26
+ : displayMessage.padEnd(innerWidth);
27
+ lines.push(`{${borderColor}-fg}${borderChar}{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}${borderChar}{/${borderColor}-fg}`);
28
+ // Bottom border
29
+ lines.push(`{${borderColor}-fg}\u2514${'─'.repeat(innerWidth + 2)}\u2518{/${borderColor}-fg}`);
30
+ lines.push('');
31
+ // Amend checkbox
32
+ const checkbox = state.amend ? '[x]' : '[ ]';
33
+ const checkboxColor = state.amend ? 'green' : 'gray';
34
+ lines.push(`{${checkboxColor}-fg}${checkbox}{/${checkboxColor}-fg} Amend {gray-fg}(a){/gray-fg}`);
35
+ // Error message
36
+ if (state.error) {
37
+ lines.push('');
38
+ lines.push(`{red-fg}${state.error}{/red-fg}`);
39
+ }
40
+ // Committing status
41
+ if (state.isCommitting) {
42
+ lines.push('');
43
+ lines.push('{yellow-fg}Committing...{/yellow-fg}');
44
+ }
45
+ lines.push('');
46
+ // Help text
47
+ const helpText = state.inputFocused
48
+ ? 'Enter: commit | Esc: unfocus'
49
+ : 'i/Enter: edit | Esc: cancel | a: toggle amend';
50
+ lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
51
+ return lines.join('\n');
52
+ }
53
+ /**
54
+ * Format inactive commit panel.
55
+ */
56
+ export function formatCommitPanelInactive() {
57
+ return "{gray-fg}Press '2' or 'c' to open commit panel{/gray-fg}";
58
+ }