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,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
+ }
@@ -0,0 +1,216 @@
1
+ import { formatDate } from '../../utils/formatDate.js';
2
+ import { formatCommitDisplay } from '../../utils/commitFormat.js';
3
+ import { shortenPath } from '../../utils/formatPath.js';
4
+ /**
5
+ * Build the list of row items for the compare list view.
6
+ */
7
+ export function buildCompareListRows(commits, files, commitsExpanded = true, filesExpanded = true) {
8
+ const result = [];
9
+ // Commits section
10
+ if (commits.length > 0) {
11
+ result.push({ type: 'section-header', sectionType: 'commits' });
12
+ if (commitsExpanded) {
13
+ commits.forEach((commit, i) => {
14
+ result.push({ type: 'commit', commitIndex: i, commit });
15
+ });
16
+ }
17
+ }
18
+ // Files section
19
+ if (files.length > 0) {
20
+ if (commits.length > 0) {
21
+ result.push({ type: 'spacer' });
22
+ }
23
+ result.push({ type: 'section-header', sectionType: 'files' });
24
+ if (filesExpanded) {
25
+ files.forEach((file, i) => {
26
+ result.push({ type: 'file', fileIndex: i, file });
27
+ });
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+ /**
33
+ * Escape blessed tags in content.
34
+ */
35
+ function escapeContent(content) {
36
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
37
+ }
38
+ /**
39
+ * Format a commit row.
40
+ */
41
+ function formatCommitRow(commit, isSelected, isFocused, width) {
42
+ const isHighlighted = isSelected && isFocused;
43
+ const dateStr = formatDate(commit.date);
44
+ // Fixed parts: indent(2) + hash(7) + spaces(4) + date + parens(2)
45
+ const baseWidth = 2 + 7 + 4 + dateStr.length + 2;
46
+ const remainingWidth = Math.max(10, width - baseWidth);
47
+ const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
48
+ let line = ' ';
49
+ line += `{yellow-fg}${commit.shortHash}{/yellow-fg} `;
50
+ if (isHighlighted) {
51
+ line += `{cyan-fg}{inverse}${escapeContent(displayMessage)}{/inverse}{/cyan-fg}`;
52
+ }
53
+ else {
54
+ line += escapeContent(displayMessage);
55
+ }
56
+ line += ` {gray-fg}(${dateStr}){/gray-fg}`;
57
+ if (displayRefs) {
58
+ line += ` {green-fg}${escapeContent(displayRefs)}{/green-fg}`;
59
+ }
60
+ return line;
61
+ }
62
+ /**
63
+ * Format a file row.
64
+ */
65
+ function formatFileRow(file, isSelected, isFocused, maxPathLength) {
66
+ const isHighlighted = isSelected && isFocused;
67
+ const isUncommitted = file.isUncommitted ?? false;
68
+ const statusColors = {
69
+ added: 'green',
70
+ modified: 'yellow',
71
+ deleted: 'red',
72
+ renamed: 'blue',
73
+ };
74
+ const statusChars = {
75
+ added: 'A',
76
+ modified: 'M',
77
+ deleted: 'D',
78
+ renamed: 'R',
79
+ };
80
+ // Account for stats: " (+123 -456)" and possible "*" for uncommitted
81
+ const statsLength = 5 + String(file.additions).length + String(file.deletions).length;
82
+ const uncommittedLength = isUncommitted ? 14 : 0;
83
+ const availableForPath = Math.max(10, maxPathLength - statsLength - uncommittedLength);
84
+ let line = ' ';
85
+ if (isUncommitted) {
86
+ line += '{magenta-fg}{bold}*{/bold}{/magenta-fg}';
87
+ }
88
+ const statusColor = isUncommitted ? 'magenta' : statusColors[file.status];
89
+ line += `{${statusColor}-fg}{bold}${statusChars[file.status]}{/bold}{/${statusColor}-fg} `;
90
+ const displayPath = shortenPath(file.path, availableForPath);
91
+ if (isHighlighted) {
92
+ line += `{cyan-fg}{inverse}${escapeContent(displayPath)}{/inverse}{/cyan-fg}`;
93
+ }
94
+ else if (isUncommitted) {
95
+ line += `{magenta-fg}${escapeContent(displayPath)}{/magenta-fg}`;
96
+ }
97
+ else {
98
+ line += escapeContent(displayPath);
99
+ }
100
+ line += ` {gray-fg}({/gray-fg}{green-fg}+${file.additions}{/green-fg} {red-fg}-${file.deletions}{/red-fg}{gray-fg}){/gray-fg}`;
101
+ if (isUncommitted) {
102
+ line += ' {magenta-fg}[uncommitted]{/magenta-fg}';
103
+ }
104
+ return line;
105
+ }
106
+ /**
107
+ * Format the compare list view as blessed-compatible tagged string.
108
+ */
109
+ export function formatCompareListView(commits, files, selectedItem, isFocused, width, scrollOffset = 0, maxHeight) {
110
+ if (commits.length === 0 && files.length === 0) {
111
+ return '{gray-fg}No changes compared to base branch{/gray-fg}';
112
+ }
113
+ const rows = buildCompareListRows(commits, files);
114
+ // Apply scroll offset and max height
115
+ const visibleRows = maxHeight
116
+ ? rows.slice(scrollOffset, scrollOffset + maxHeight)
117
+ : rows.slice(scrollOffset);
118
+ const lines = [];
119
+ for (const row of visibleRows) {
120
+ if (row.type === 'section-header') {
121
+ const isCommits = row.sectionType === 'commits';
122
+ const count = isCommits ? commits.length : files.length;
123
+ const label = isCommits ? 'Commits' : 'Files';
124
+ lines.push(`{cyan-fg}{bold}▼ ${label}{/bold}{/cyan-fg} {gray-fg}(${count}){/gray-fg}`);
125
+ }
126
+ else if (row.type === 'spacer') {
127
+ lines.push('');
128
+ }
129
+ else if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
130
+ const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
131
+ lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
132
+ }
133
+ else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
134
+ const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
135
+ lines.push(formatFileRow(row.file, isSelected, isFocused, width - 5));
136
+ }
137
+ }
138
+ return lines.join('\n');
139
+ }
140
+ /**
141
+ * Get the total number of rows in the compare list view (for scroll calculation).
142
+ */
143
+ export function getCompareListTotalRows(commits, files, commitsExpanded = true, filesExpanded = true) {
144
+ let count = 0;
145
+ if (commits.length > 0) {
146
+ count += 1; // header
147
+ if (commitsExpanded)
148
+ count += commits.length;
149
+ }
150
+ if (files.length > 0) {
151
+ if (commits.length > 0)
152
+ count += 1; // spacer
153
+ count += 1; // header
154
+ if (filesExpanded)
155
+ count += files.length;
156
+ }
157
+ return count;
158
+ }
159
+ /**
160
+ * Map a row index to a selection.
161
+ * Returns null if the row is a header or spacer.
162
+ */
163
+ export function getCompareSelectionFromRow(rowIndex, commits, files, commitsExpanded = true, filesExpanded = true) {
164
+ const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
165
+ const row = rows[rowIndex];
166
+ if (!row)
167
+ return null;
168
+ if (row.type === 'commit' && row.commitIndex !== undefined) {
169
+ return { type: 'commit', index: row.commitIndex };
170
+ }
171
+ if (row.type === 'file' && row.fileIndex !== undefined) {
172
+ return { type: 'file', index: row.fileIndex };
173
+ }
174
+ return null;
175
+ }
176
+ /**
177
+ * Find the row index for a given selection.
178
+ */
179
+ export function getRowFromCompareSelection(selection, commits, files, commitsExpanded = true, filesExpanded = true) {
180
+ const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
181
+ for (let i = 0; i < rows.length; i++) {
182
+ const row = rows[i];
183
+ if (selection.type === 'commit' &&
184
+ row.type === 'commit' &&
185
+ row.commitIndex === selection.index) {
186
+ return i;
187
+ }
188
+ if (selection.type === 'file' && row.type === 'file' && row.fileIndex === selection.index) {
189
+ return i;
190
+ }
191
+ }
192
+ return 0;
193
+ }
194
+ /**
195
+ * Navigate to next selectable item.
196
+ */
197
+ export function getNextCompareSelection(current, commits, files, direction) {
198
+ const rows = buildCompareListRows(commits, files);
199
+ // Find current row index
200
+ let currentRowIndex = 0;
201
+ if (current) {
202
+ currentRowIndex = getRowFromCompareSelection(current, commits, files);
203
+ }
204
+ // Find next selectable row
205
+ const delta = direction === 'down' ? 1 : -1;
206
+ let nextRowIndex = currentRowIndex + delta;
207
+ while (nextRowIndex >= 0 && nextRowIndex < rows.length) {
208
+ const selection = getCompareSelectionFromRow(nextRowIndex, commits, files);
209
+ if (selection) {
210
+ return selection;
211
+ }
212
+ nextRowIndex += delta;
213
+ }
214
+ // Stay at current if no valid next selection
215
+ return current;
216
+ }
@@ -0,0 +1,279 @@
1
+ import { getTheme } from '../../themes.js';
2
+ import { buildDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, } from '../../utils/displayRows.js';
3
+ import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
4
+ import { truncateAnsi } from '../../utils/ansiTruncate.js';
5
+ /**
6
+ * Truncate string to fit within maxWidth, adding ellipsis if needed.
7
+ */
8
+ function truncate(str, maxWidth) {
9
+ if (maxWidth <= 0 || str.length <= maxWidth)
10
+ return str;
11
+ if (maxWidth <= 1)
12
+ return '\u2026';
13
+ return str.slice(0, maxWidth - 1) + '\u2026';
14
+ }
15
+ /**
16
+ * Format line number with padding.
17
+ */
18
+ function formatLineNum(lineNum, width) {
19
+ if (lineNum === undefined)
20
+ return ' '.repeat(width);
21
+ return String(lineNum).padStart(width, ' ');
22
+ }
23
+ /**
24
+ * Escape blessed tags in content.
25
+ */
26
+ function escapeContent(content) {
27
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
28
+ }
29
+ /**
30
+ * Build raw ANSI escape sequence for 24-bit RGB background.
31
+ */
32
+ function ansiBg(hex) {
33
+ const r = parseInt(hex.slice(1, 3), 16);
34
+ const g = parseInt(hex.slice(3, 5), 16);
35
+ const b = parseInt(hex.slice(5, 7), 16);
36
+ return `\x1b[48;2;${r};${g};${b}m`;
37
+ }
38
+ /**
39
+ * Build raw ANSI escape sequence for 24-bit RGB foreground.
40
+ */
41
+ function ansiFg(hex) {
42
+ const r = parseInt(hex.slice(1, 3), 16);
43
+ const g = parseInt(hex.slice(3, 5), 16);
44
+ const b = parseInt(hex.slice(5, 7), 16);
45
+ return `\x1b[38;2;${r};${g};${b}m`;
46
+ }
47
+ const ANSI_RESET = '\x1b[0m';
48
+ /**
49
+ * Format a single display row as blessed-compatible tagged string.
50
+ */
51
+ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode) {
52
+ const { colors } = theme;
53
+ switch (row.type) {
54
+ case 'diff-header': {
55
+ const content = row.content;
56
+ if (content.startsWith('diff --git')) {
57
+ const match = content.match(/diff --git a\/.+ b\/(.+)$/);
58
+ if (match) {
59
+ const maxPathLen = headerWidth - 6;
60
+ const path = truncate(match[1], maxPathLen);
61
+ return `{bold}{cyan-fg}\u2500\u2500 ${path} \u2500\u2500{/cyan-fg}{/bold}`;
62
+ }
63
+ }
64
+ return `{gray-fg}${escapeContent(truncate(content, headerWidth))}{/gray-fg}`;
65
+ }
66
+ case 'diff-hunk': {
67
+ const match = row.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
68
+ if (match) {
69
+ const oldStart = parseInt(match[1], 10);
70
+ const oldCount = match[2] ? parseInt(match[2], 10) : 1;
71
+ const newStart = parseInt(match[3], 10);
72
+ const newCount = match[4] ? parseInt(match[4], 10) : 1;
73
+ const context = match[5].trim();
74
+ const oldEnd = oldStart + oldCount - 1;
75
+ const newEnd = newStart + newCount - 1;
76
+ const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart}-${oldEnd}`;
77
+ const newRange = newCount === 1 ? `${newStart}` : `${newStart}-${newEnd}`;
78
+ const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
79
+ const contextMaxLen = headerWidth - rangeText.length - 1;
80
+ const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
81
+ return `{cyan-fg}${rangeText}{/cyan-fg}{gray-fg}${truncatedContext}{/gray-fg}`;
82
+ }
83
+ return `{cyan-fg}${escapeContent(truncate(row.content, headerWidth))}{/cyan-fg}`;
84
+ }
85
+ case 'diff-add': {
86
+ const isCont = row.isContinuation;
87
+ const symbol = isCont ? '\u00bb' : '+';
88
+ const lineNum = formatLineNum(row.lineNum, lineNumWidth);
89
+ const prefix = `${lineNum} ${symbol} `;
90
+ if (theme.name.includes('ansi')) {
91
+ // Basic ANSI theme - use blessed tags with 16-color palette
92
+ const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
93
+ const visibleContent = `${prefix}${rawContent}`;
94
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
95
+ return `{green-bg}{white-fg}${escapeContent(paddedContent)}{/white-fg}{/green-bg}`;
96
+ }
97
+ // Use 24-bit RGB via {escape} tag
98
+ const bg = ansiBg(colors.addBg);
99
+ const highlightBg = ansiBg(colors.addHighlight);
100
+ const fg = ansiFg('#ffffff');
101
+ // Check for word-level diff segments (only in non-wrap mode or first row)
102
+ if (row.wordDiffSegments && !isCont) {
103
+ const rawContent = row.content || '';
104
+ // Check visible content length (not including escape codes)
105
+ if (!wrapMode && rawContent.length > contentWidth) {
106
+ // Content too long - fall back to simple truncation without word highlighting
107
+ const truncated = truncate(rawContent, contentWidth);
108
+ const visibleContent = `${prefix}${truncated}`;
109
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
110
+ return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
111
+ }
112
+ // Build content with word-level highlighting
113
+ let contentStr = '';
114
+ for (const seg of row.wordDiffSegments) {
115
+ if (seg.type === 'changed') {
116
+ contentStr += `${highlightBg}${seg.text}${bg}`;
117
+ }
118
+ else {
119
+ contentStr += seg.text;
120
+ }
121
+ }
122
+ // Calculate padding based on visible width (prefix + rawContent)
123
+ const visibleWidth = prefix.length + rawContent.length;
124
+ const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
125
+ return `{escape}${bg}${fg}${prefix}${contentStr}${padding}${ANSI_RESET}{/escape}`;
126
+ }
127
+ // Check for syntax highlighting (when no word-diff)
128
+ if (row.highlighted && !isCont) {
129
+ const rawContent = row.content || '';
130
+ if (!wrapMode && rawContent.length > contentWidth) {
131
+ // Too long - fall back to plain truncation
132
+ const truncated = truncate(rawContent, contentWidth);
133
+ const visibleContent = `${prefix}${truncated}`;
134
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
135
+ return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
136
+ }
137
+ // Use highlighted content (already has foreground colors, bg preserved)
138
+ const visibleWidth = prefix.length + rawContent.length;
139
+ const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
140
+ return `{escape}${bg}${fg}${prefix}${row.highlighted}${padding}${ANSI_RESET}{/escape}`;
141
+ }
142
+ const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
143
+ const visibleContent = `${prefix}${rawContent}`;
144
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
145
+ return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
146
+ }
147
+ case 'diff-del': {
148
+ const isCont = row.isContinuation;
149
+ const symbol = isCont ? '\u00bb' : '-';
150
+ const lineNum = formatLineNum(row.lineNum, lineNumWidth);
151
+ const prefix = `${lineNum} ${symbol} `;
152
+ if (theme.name.includes('ansi')) {
153
+ // Basic ANSI theme - use blessed tags with 16-color palette
154
+ const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
155
+ const visibleContent = `${prefix}${rawContent}`;
156
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
157
+ return `{red-bg}{white-fg}${escapeContent(paddedContent)}{/white-fg}{/red-bg}`;
158
+ }
159
+ // Use 24-bit RGB via {escape} tag
160
+ const bg = ansiBg(colors.delBg);
161
+ const highlightBg = ansiBg(colors.delHighlight);
162
+ const fg = ansiFg('#ffffff');
163
+ // Check for word-level diff segments (only in non-wrap mode or first row)
164
+ if (row.wordDiffSegments && !isCont) {
165
+ const rawContent = row.content || '';
166
+ // Check visible content length (not including escape codes)
167
+ if (!wrapMode && rawContent.length > contentWidth) {
168
+ // Content too long - fall back to simple truncation without word highlighting
169
+ const truncated = truncate(rawContent, contentWidth);
170
+ const visibleContent = `${prefix}${truncated}`;
171
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
172
+ return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
173
+ }
174
+ // Build content with word-level highlighting
175
+ let contentStr = '';
176
+ for (const seg of row.wordDiffSegments) {
177
+ if (seg.type === 'changed') {
178
+ contentStr += `${highlightBg}${seg.text}${bg}`;
179
+ }
180
+ else {
181
+ contentStr += seg.text;
182
+ }
183
+ }
184
+ // Calculate padding based on visible width (prefix + rawContent)
185
+ const visibleWidth = prefix.length + rawContent.length;
186
+ const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
187
+ return `{escape}${bg}${fg}${prefix}${contentStr}${padding}${ANSI_RESET}{/escape}`;
188
+ }
189
+ // Check for syntax highlighting (when no word-diff)
190
+ if (row.highlighted && !isCont) {
191
+ const rawContent = row.content || '';
192
+ if (!wrapMode && rawContent.length > contentWidth) {
193
+ // Too long - fall back to plain truncation
194
+ const truncated = truncate(rawContent, contentWidth);
195
+ const visibleContent = `${prefix}${truncated}`;
196
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
197
+ return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
198
+ }
199
+ // Use highlighted content (already has foreground colors, bg preserved)
200
+ const visibleWidth = prefix.length + rawContent.length;
201
+ const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
202
+ return `{escape}${bg}${fg}${prefix}${row.highlighted}${padding}${ANSI_RESET}{/escape}`;
203
+ }
204
+ const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
205
+ const visibleContent = `${prefix}${rawContent}`;
206
+ const paddedContent = visibleContent.padEnd(headerWidth, ' ');
207
+ return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
208
+ }
209
+ case 'diff-context': {
210
+ const isCont = row.isContinuation;
211
+ const symbol = isCont ? '\u00bb' : ' ';
212
+ const lineNum = formatLineNum(row.lineNum, lineNumWidth);
213
+ const prefix = `${lineNum} ${symbol} `;
214
+ const rawContent = row.content || '';
215
+ // Use syntax highlighting if available (not for continuations)
216
+ if (row.highlighted && !isCont) {
217
+ const truncatedHighlight = wrapMode
218
+ ? row.highlighted
219
+ : truncateAnsi(row.highlighted, contentWidth);
220
+ const highlightedContent = ansiToBlessed(truncatedHighlight);
221
+ return `{gray-fg}${prefix}{/gray-fg}${highlightedContent}`;
222
+ }
223
+ const content = wrapMode
224
+ ? escapeContent(rawContent)
225
+ : escapeContent(truncate(rawContent, contentWidth));
226
+ return `{gray-fg}${prefix}{/gray-fg}${content}`;
227
+ }
228
+ case 'commit-header':
229
+ return `{yellow-fg}${escapeContent(truncate(row.content, headerWidth))}{/yellow-fg}`;
230
+ case 'commit-message':
231
+ return escapeContent(truncate(row.content, headerWidth));
232
+ case 'spacer':
233
+ return '';
234
+ }
235
+ }
236
+ /**
237
+ * Format diff output as blessed-compatible tagged string.
238
+ * Returns both the content and total row count for scroll calculations.
239
+ */
240
+ export function formatDiff(diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
241
+ const displayRows = buildDiffDisplayRows(diff);
242
+ if (displayRows.length === 0) {
243
+ return { content: '{gray-fg}No diff to display{/gray-fg}', totalRows: 0 };
244
+ }
245
+ const theme = getTheme(themeName);
246
+ const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
247
+ const contentWidth = width - lineNumWidth - 5; // line num + space + symbol + space + padding
248
+ const headerWidth = width - 2;
249
+ // Apply wrapping if enabled
250
+ const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
251
+ const totalRows = wrappedRows.length;
252
+ // Apply scroll offset and max height
253
+ const visibleRows = maxHeight
254
+ ? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
255
+ : wrappedRows.slice(scrollOffset);
256
+ const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
257
+ return { content: lines.join('\n'), totalRows };
258
+ }
259
+ /**
260
+ * Format history diff (commit metadata + diff) as blessed-compatible tagged string.
261
+ * Returns both the content and total row count for scroll calculations.
262
+ */
263
+ export function formatHistoryDiff(commit, diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
264
+ const displayRows = buildHistoryDisplayRows(commit, diff);
265
+ if (displayRows.length === 0) {
266
+ return { content: '{gray-fg}No commit selected{/gray-fg}', totalRows: 0 };
267
+ }
268
+ const theme = getTheme(themeName);
269
+ const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
270
+ const contentWidth = width - lineNumWidth - 5;
271
+ const headerWidth = width - 2;
272
+ const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
273
+ const totalRows = wrappedRows.length;
274
+ const visibleRows = maxHeight
275
+ ? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
276
+ : wrappedRows.slice(scrollOffset);
277
+ const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
278
+ return { content: lines.join('\n'), totalRows };
279
+ }