git-watchtower 1.6.1 → 1.7.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.
@@ -0,0 +1,1326 @@
1
+ /**
2
+ * Extracted rendering functions for Git Watchtower terminal UI.
3
+ *
4
+ * Each function takes a `state` object (plain data, no globals) and a `write`
5
+ * function (e.g. process.stdout.write bound to stdout). This makes the
6
+ * renderers pure-ish: they only read from `state` and only produce output
7
+ * through `write`, which simplifies testing and decouples them from the
8
+ * main process module.
9
+ *
10
+ * @module ui/renderer
11
+ */
12
+
13
+ const {
14
+ ansi,
15
+ box,
16
+ truncate,
17
+ padRight,
18
+ padLeft,
19
+ drawBox,
20
+ clearArea,
21
+ visibleLength,
22
+ stripAnsi,
23
+ } = require('../ui/ansi');
24
+ const { formatTimeAgo } = require('../utils/time');
25
+ const { isBaseBranch } = require('../git/pr');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // renderHeader
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Render the top header bar.
33
+ *
34
+ * @param {object} state
35
+ * @param {function} write
36
+ */
37
+ function renderHeader(state, write) {
38
+ const width = state.terminalWidth;
39
+ const headerRow = state.casinoModeEnabled ? 2 : 1;
40
+
41
+ let statusIcon = { idle: ansi.green + '\u25CF', fetching: ansi.yellow + '\u27F3', error: ansi.red + '\u25CF' }[state.pollingStatus];
42
+ if (state.isOffline) statusIcon = ansi.red + '\u2298';
43
+
44
+ const soundIcon = state.soundEnabled ? ansi.green + '\uD83D\uDD14' : ansi.gray + '\uD83D\uDD15';
45
+
46
+ write(ansi.moveTo(headerRow, 1));
47
+ write(ansi.bgBlue + ansi.white + ansi.bold);
48
+
49
+ const leftContent = ` \uD83C\uDFF0 Git Watchtower ${ansi.dim}\u2502${ansi.bold} ${state.projectName}`;
50
+ const leftVisibleLen = 21 + state.projectName.length;
51
+ write(leftContent);
52
+
53
+ let badges = '';
54
+ let badgesVisibleLen = 0;
55
+
56
+ if (state.serverMode === 'command' && state.serverCrashed) {
57
+ const label = ' CRASHED ';
58
+ badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
59
+ badgesVisibleLen += 1 + label.length;
60
+ }
61
+ if (state.isOffline) {
62
+ const label = ' OFFLINE ';
63
+ badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
64
+ badgesVisibleLen += 1 + label.length;
65
+ }
66
+ if (state.isDetachedHead) {
67
+ const label = ' DETACHED HEAD ';
68
+ badges += ' ' + ansi.bgYellow + ansi.black + label + ansi.bgBlue + ansi.white;
69
+ badgesVisibleLen += 1 + label.length;
70
+ }
71
+ if (state.hasMergeConflict) {
72
+ const label = ' MERGE CONFLICT ';
73
+ badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
74
+ badgesVisibleLen += 1 + label.length;
75
+ }
76
+ write(badges);
77
+
78
+ let modeLabel = '';
79
+ let modeBadge = '';
80
+ if (state.serverMode === 'static') {
81
+ modeLabel = ' STATIC ';
82
+ modeBadge = ansi.bgCyan + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
83
+ } else if (state.serverMode === 'command') {
84
+ modeLabel = ' COMMAND ';
85
+ modeBadge = ansi.bgGreen + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
86
+ } else {
87
+ modeLabel = ' MONITOR ';
88
+ modeBadge = ansi.bgMagenta + ansi.white + modeLabel + ansi.bgBlue + ansi.white;
89
+ }
90
+
91
+ let serverInfo = '';
92
+ let serverInfoVisible = '';
93
+ if (state.serverMode === 'none') {
94
+ serverInfoVisible = '';
95
+ } else {
96
+ const statusDot = state.serverRunning
97
+ ? ansi.green + '\u25CF'
98
+ : (state.serverCrashed ? ansi.red + '\u25CF' : ansi.gray + '\u25CB');
99
+ serverInfoVisible = `localhost:${state.port} `;
100
+ serverInfo = statusDot + ansi.white + ` localhost:${state.port} `;
101
+ }
102
+
103
+ const rightContent = `${modeBadge} ${serverInfo}${statusIcon}${ansi.bgBlue} ${soundIcon}${ansi.bgBlue} `;
104
+ const rightVisibleLen = modeLabel.length + 1 + serverInfoVisible.length + 5;
105
+
106
+ const usedSpace = leftVisibleLen + badgesVisibleLen + rightVisibleLen;
107
+ const padding = Math.max(1, width - usedSpace);
108
+ write(' '.repeat(padding));
109
+ write(rightContent);
110
+ write(ansi.reset);
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // renderBranchList
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Render the branch list box. Returns the row number at the bottom of the
119
+ * box so that subsequent sections know where to start.
120
+ *
121
+ * @param {object} state
122
+ * @param {function} write
123
+ * @returns {number} The row immediately after the branch list box.
124
+ */
125
+ function renderBranchList(state, write) {
126
+ const startRow = state.casinoModeEnabled ? 4 : 3;
127
+ const boxWidth = state.terminalWidth;
128
+ const contentWidth = boxWidth - 4;
129
+ const height = Math.min(state.visibleBranchCount * 2 + 4, Math.floor(state.terminalHeight * 0.5));
130
+
131
+ const displayBranches = state.filteredBranches !== null ? state.filteredBranches : state.branches;
132
+ const boxTitle = state.searchMode
133
+ ? `BRANCHES (/${state.searchQuery}_)`
134
+ : 'ACTIVE BRANCHES';
135
+
136
+ write(drawBox(startRow, 1, boxWidth, height, boxTitle, ansi.cyan));
137
+
138
+ // Clear content area
139
+ for (let i = 1; i < height - 1; i++) {
140
+ write(ansi.moveTo(startRow + i, 2));
141
+ write(' '.repeat(contentWidth + 2));
142
+ }
143
+
144
+ // Header line
145
+ write(ansi.moveTo(startRow + 1, 2));
146
+ write(ansi.gray + '\u2500'.repeat(contentWidth + 2) + ansi.reset);
147
+
148
+ if (displayBranches.length === 0) {
149
+ write(ansi.moveTo(startRow + 3, 4));
150
+ if (state.searchMode && state.searchQuery) {
151
+ write(ansi.gray + `No branches matching "${state.searchQuery}"` + ansi.reset);
152
+ } else {
153
+ write(ansi.gray + "No branches found. Press 'f' to fetch." + ansi.reset);
154
+ }
155
+ return startRow + height;
156
+ }
157
+
158
+ let row = startRow + 2;
159
+ for (let i = 0; i < displayBranches.length && i < state.visibleBranchCount; i++) {
160
+ const branch = displayBranches[i];
161
+ const isSelected = i === state.selectedIndex;
162
+ const isCurrent = branch.name === state.currentBranch;
163
+ const timeAgo = formatTimeAgo(branch.date);
164
+ const sparkline = state.sparklineCache.get(branch.name) || ' ';
165
+ const prStatus = state.branchPrStatusMap.get(branch.name);
166
+ const isBranchBase = isBaseBranch(branch.name);
167
+ const isMerged = !isBranchBase && prStatus && prStatus.state === 'MERGED';
168
+ const hasOpenPr = prStatus && prStatus.state === 'OPEN';
169
+
170
+ write(ansi.moveTo(row, 2));
171
+ const cursor = isSelected ? ' \u25B6 ' : ' ';
172
+ const maxNameLen = contentWidth - 38;
173
+ const displayName = truncate(branch.name, maxNameLen);
174
+ const namePadding = Math.max(1, maxNameLen - displayName.length + 2);
175
+
176
+ if (isSelected) write(ansi.inverse);
177
+ write(cursor);
178
+
179
+ if (branch.isDeleted) {
180
+ write(ansi.gray + ansi.dim + displayName + ansi.reset);
181
+ if (isSelected) write(ansi.inverse);
182
+ } else if (isMerged && !isCurrent) {
183
+ write(ansi.dim + ansi.fg256(103) + displayName + ansi.reset);
184
+ if (isSelected) write(ansi.inverse);
185
+ } else if (isCurrent) {
186
+ write(ansi.green + ansi.bold + displayName + ansi.reset);
187
+ if (isSelected) write(ansi.inverse);
188
+ } else if (branch.justUpdated) {
189
+ write(ansi.yellow + displayName + ansi.reset);
190
+ if (isSelected) write(ansi.inverse);
191
+ } else {
192
+ write(displayName);
193
+ }
194
+
195
+ write(' '.repeat(namePadding));
196
+
197
+ // Sparkline
198
+ if (isSelected) write(ansi.reset);
199
+ if (isMerged && !isCurrent) {
200
+ write(ansi.dim + ansi.fg256(60) + sparkline + ansi.reset);
201
+ } else {
202
+ write(ansi.fg256(39) + sparkline + ansi.reset);
203
+ }
204
+ if (isSelected) write(ansi.inverse);
205
+
206
+ // PR status dot
207
+ if (isSelected) write(ansi.reset);
208
+ if (isMerged) {
209
+ write(ansi.dim + ansi.magenta + '\u25CF' + ansi.reset);
210
+ } else if (hasOpenPr) {
211
+ write(ansi.brightGreen + '\u25CF' + ansi.reset);
212
+ } else {
213
+ write(' ');
214
+ }
215
+ if (isSelected) write(ansi.inverse);
216
+
217
+ // Status badge
218
+ if (branch.isDeleted) {
219
+ if (isSelected) write(ansi.reset);
220
+ write(ansi.red + ansi.dim + '\u2717 DELETED' + ansi.reset);
221
+ if (isSelected) write(ansi.inverse);
222
+ } else if (isMerged && !isCurrent && !branch.isNew && !branch.hasUpdates) {
223
+ if (isSelected) write(ansi.reset);
224
+ write(ansi.dim + ansi.magenta + '\u2713 MERGED ' + ansi.reset);
225
+ if (isSelected) write(ansi.inverse);
226
+ } else if (isCurrent) {
227
+ if (isSelected) write(ansi.reset);
228
+ write(ansi.green + '\u2605 CURRENT' + ansi.reset);
229
+ if (isSelected) write(ansi.inverse);
230
+ } else if (branch.isNew) {
231
+ if (isSelected) write(ansi.reset);
232
+ write(ansi.magenta + '\u2726 NEW ' + ansi.reset);
233
+ if (isSelected) write(ansi.inverse);
234
+ } else if (branch.hasUpdates) {
235
+ if (isSelected) write(ansi.reset);
236
+ write(ansi.yellow + '\u2193 UPDATES' + ansi.reset);
237
+ if (isSelected) write(ansi.inverse);
238
+ } else {
239
+ write(' ');
240
+ }
241
+
242
+ // Time ago
243
+ write(' ');
244
+ if (isSelected) write(ansi.reset);
245
+ write(ansi.gray + padLeft(timeAgo, 10) + ansi.reset);
246
+ if (isSelected) write(ansi.reset);
247
+
248
+ row++;
249
+
250
+ // Commit info line
251
+ write(ansi.moveTo(row, 2));
252
+ if (isMerged && !isCurrent) {
253
+ write(ansi.dim + ' \u2514\u2500 ' + ansi.reset);
254
+ write(ansi.dim + ansi.cyan + (branch.commit || '???????') + ansi.reset);
255
+ write(ansi.dim + ' \u2022 ' + ansi.reset);
256
+ const prTag = ansi.dim + ansi.magenta + '#' + prStatus.number + ansi.reset + ansi.dim + ' ';
257
+ write(prTag + ansi.gray + ansi.dim + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
258
+ } else {
259
+ write(' \u2514\u2500 ');
260
+ write(ansi.cyan + (branch.commit || '???????') + ansi.reset);
261
+ write(' \u2022 ');
262
+ if (hasOpenPr) {
263
+ const prTag = ansi.brightGreen + '#' + prStatus.number + ansi.reset + ' ';
264
+ write(prTag + ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
265
+ } else {
266
+ write(ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 22) + ansi.reset);
267
+ }
268
+ }
269
+
270
+ row++;
271
+ }
272
+
273
+ return startRow + height;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // renderActivityLog
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Render the activity log box below the branch list.
282
+ *
283
+ * @param {object} state
284
+ * @param {function} write
285
+ * @param {number} startRow - Row where the box should begin.
286
+ * @returns {number} The row immediately after the activity log box.
287
+ */
288
+ function renderActivityLog(state, write, startRow) {
289
+ const boxWidth = state.terminalWidth;
290
+ const contentWidth = boxWidth - 4;
291
+ const height = Math.min(state.maxLogEntries + 3, state.terminalHeight - startRow - 4);
292
+
293
+ write(drawBox(startRow, 1, boxWidth, height, 'ACTIVITY LOG', ansi.gray));
294
+
295
+ for (let i = 1; i < height - 1; i++) {
296
+ write(ansi.moveTo(startRow + i, 2));
297
+ write(' '.repeat(contentWidth + 2));
298
+ }
299
+
300
+ let row = startRow + 1;
301
+ for (let i = 0; i < state.activityLog.length && i < height - 2; i++) {
302
+ const entry = state.activityLog[i];
303
+ write(ansi.moveTo(row, 3));
304
+ write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
305
+ write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
306
+ write(truncate(entry.message, contentWidth - 16));
307
+ row++;
308
+ }
309
+
310
+ if (state.activityLog.length === 0) {
311
+ write(ansi.moveTo(startRow + 1, 3));
312
+ write(ansi.gray + 'No activity yet...' + ansi.reset);
313
+ }
314
+
315
+ return startRow + height;
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // renderCasinoStats (stub)
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /**
323
+ * Placeholder for casino stats rendering. The actual casino rendering
324
+ * depends on the casino module and stays in bin/ for now.
325
+ *
326
+ * @param {object} state
327
+ * @param {function} write
328
+ * @param {number} startRow
329
+ * @returns {number} The unchanged startRow (no-op).
330
+ */
331
+ function renderCasinoStats(state, write, startRow) {
332
+ if (!state.casinoModeEnabled) return startRow;
333
+ // Delegates to casino module; actual rendering stays in bin/
334
+ return startRow;
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // renderFooter
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Render the bottom footer/key-binding bar.
343
+ *
344
+ * @param {object} state
345
+ * @param {function} write
346
+ */
347
+ function renderFooter(state, write) {
348
+ const row = state.terminalHeight - 1;
349
+ write(ansi.moveTo(row, 1));
350
+ write(ansi.bgBlack + ansi.white);
351
+ write(' ');
352
+ write(ansi.gray + '[\u2191\u2193]' + ansi.reset + ansi.bgBlack + ' Nav ');
353
+ write(ansi.gray + '[/]' + ansi.reset + ansi.bgBlack + ' Search ');
354
+ write(ansi.gray + '[v]' + ansi.reset + ansi.bgBlack + ' Preview ');
355
+ write(ansi.gray + '[Enter]' + ansi.reset + ansi.bgBlack + ' Switch ');
356
+ write(ansi.gray + '[h]' + ansi.reset + ansi.bgBlack + ' History ');
357
+ write(ansi.gray + '[i]' + ansi.reset + ansi.bgBlack + ' Info ');
358
+ write(ansi.gray + '[b]' + ansi.reset + ansi.bgBlack + ' Actions ');
359
+
360
+ if (!state.noServer) {
361
+ write(ansi.gray + '[l]' + ansi.reset + ansi.bgBlack + ' Logs ');
362
+ write(ansi.gray + '[o]' + ansi.reset + ansi.bgBlack + ' Open ');
363
+ }
364
+ if (state.serverMode === 'static') {
365
+ write(ansi.gray + '[r]' + ansi.reset + ansi.bgBlack + ' Reload ');
366
+ } else if (state.serverMode === 'command') {
367
+ write(ansi.gray + '[R]' + ansi.reset + ansi.bgBlack + ' Restart ');
368
+ }
369
+
370
+ write(ansi.gray + '[\u00B1]' + ansi.reset + ansi.bgBlack + ' List:' + ansi.cyan + state.visibleBranchCount + ansi.reset + ansi.bgBlack + ' ');
371
+
372
+ if (state.casinoModeEnabled) {
373
+ write(ansi.brightMagenta + '[c]' + ansi.reset + ansi.bgBlack + ' \uD83C\uDFB0 ');
374
+ } else {
375
+ write(ansi.gray + '[c]' + ansi.reset + ansi.bgBlack + ' Casino ');
376
+ }
377
+
378
+ write(ansi.gray + '[d]' + ansi.reset + ansi.bgBlack + ' Cleanup ');
379
+ write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
380
+ write(ansi.reset);
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // renderFlash
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Render a centered flash notification overlay (e.g. "NEW UPDATE").
389
+ *
390
+ * @param {object} state
391
+ * @param {function} write
392
+ */
393
+ function renderFlash(state, write) {
394
+ if (!state.flashMessage) return;
395
+
396
+ const width = 50;
397
+ const height = 5;
398
+ const col = Math.floor((state.terminalWidth - width) / 2);
399
+ const row = Math.floor((state.terminalHeight - height) / 2);
400
+
401
+ // Draw double-line box
402
+ write(ansi.moveTo(row, col));
403
+ write(ansi.yellow + ansi.bold);
404
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
405
+
406
+ for (let i = 1; i < height - 1; i++) {
407
+ write(ansi.moveTo(row + i, col));
408
+ write(box.dVertical + ' '.repeat(width - 2) + box.dVertical);
409
+ }
410
+
411
+ write(ansi.moveTo(row + height - 1, col));
412
+ write(box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
413
+ write(ansi.reset);
414
+
415
+ // Content
416
+ write(ansi.moveTo(row + 1, col + Math.floor((width - 16) / 2)));
417
+ write(ansi.yellow + ansi.bold + '\u26A1 NEW UPDATE \u26A1' + ansi.reset);
418
+
419
+ write(ansi.moveTo(row + 2, col + 2));
420
+ const truncMsg = truncate(state.flashMessage, width - 4);
421
+ write(ansi.white + truncMsg + ansi.reset);
422
+
423
+ write(ansi.moveTo(row + 3, col + Math.floor((width - 22) / 2)));
424
+ write(ansi.gray + 'Press any key to dismiss' + ansi.reset);
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // renderErrorToast
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * Render a centered error toast overlay.
433
+ *
434
+ * @param {object} state
435
+ * @param {function} write
436
+ */
437
+ function renderErrorToast(state, write) {
438
+ if (!state.errorToast) return;
439
+
440
+ const width = Math.min(60, state.terminalWidth - 4);
441
+ const col = Math.floor((state.terminalWidth - width) / 2);
442
+ const row = 2; // Near the top, below header
443
+
444
+ // Calculate height based on content
445
+ const lines = [];
446
+ lines.push(state.errorToast.title || 'Git Error');
447
+ lines.push('');
448
+
449
+ // Word wrap the message
450
+ const msgWords = state.errorToast.message.split(' ');
451
+ let currentLine = '';
452
+ for (const word of msgWords) {
453
+ if ((currentLine + ' ' + word).length > width - 6) {
454
+ lines.push(currentLine.trim());
455
+ currentLine = word;
456
+ } else {
457
+ currentLine += (currentLine ? ' ' : '') + word;
458
+ }
459
+ }
460
+ if (currentLine) lines.push(currentLine.trim());
461
+
462
+ if (state.errorToast.hint) {
463
+ lines.push('');
464
+ lines.push(state.errorToast.hint);
465
+ }
466
+ lines.push('');
467
+ lines.push('Press any key to dismiss');
468
+
469
+ const height = lines.length + 2;
470
+
471
+ // Draw red error box
472
+ write(ansi.moveTo(row, col));
473
+ write(ansi.red + ansi.bold);
474
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
475
+
476
+ for (let i = 1; i < height - 1; i++) {
477
+ write(ansi.moveTo(row + i, col));
478
+ write(ansi.red + box.dVertical + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 2) + ansi.reset + ansi.red + box.dVertical + ansi.reset);
479
+ }
480
+
481
+ write(ansi.moveTo(row + height - 1, col));
482
+ write(ansi.red + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
483
+ write(ansi.reset);
484
+
485
+ // Render content
486
+ let contentRow = row + 1;
487
+ for (let i = 0; i < lines.length; i++) {
488
+ const line = lines[i];
489
+ write(ansi.moveTo(contentRow, col + 2));
490
+ write(ansi.bgRed + ansi.white);
491
+
492
+ if (i === 0) {
493
+ // Title line - centered and bold
494
+ const titlePadding = Math.floor((width - 4 - line.length) / 2);
495
+ write(' '.repeat(titlePadding) + ansi.bold + line + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 4 - titlePadding - line.length));
496
+ } else if (line === 'Press any key to dismiss') {
497
+ // Instruction line - centered and dimmer
498
+ const lPadding = Math.floor((width - 4 - line.length) / 2);
499
+ write(ansi.reset + ansi.bgRed + ansi.gray + ' '.repeat(lPadding) + line + ' '.repeat(width - 4 - lPadding - line.length));
500
+ } else if (state.errorToast.hint && line === state.errorToast.hint) {
501
+ // Hint line - yellow on red
502
+ const lPadding = Math.floor((width - 4 - line.length) / 2);
503
+ write(ansi.reset + ansi.bgRed + ansi.yellow + ' '.repeat(lPadding) + line + ' '.repeat(width - 4 - lPadding - line.length));
504
+ } else {
505
+ // Regular content
506
+ write(padRight(line, width - 4));
507
+ }
508
+ write(ansi.reset);
509
+ contentRow++;
510
+ }
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // renderPreview
515
+ // ---------------------------------------------------------------------------
516
+
517
+ /**
518
+ * Render the branch preview overlay showing recent commits and changed files.
519
+ *
520
+ * @param {object} state
521
+ * @param {function} write
522
+ */
523
+ function renderPreview(state, write) {
524
+ if (!state.previewMode || !state.previewData) return;
525
+
526
+ const width = Math.min(60, state.terminalWidth - 4);
527
+ const height = 16;
528
+ const col = Math.floor((state.terminalWidth - width) / 2);
529
+ const row = Math.floor((state.terminalHeight - height) / 2);
530
+
531
+ const displayBranches = state.filteredBranches !== null ? state.filteredBranches : state.branches;
532
+ const branch = displayBranches[state.selectedIndex];
533
+ if (!branch) return;
534
+
535
+ // Draw box
536
+ write(ansi.moveTo(row, col));
537
+ write(ansi.cyan + ansi.bold);
538
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
539
+
540
+ for (let i = 1; i < height - 1; i++) {
541
+ write(ansi.moveTo(row + i, col));
542
+ write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
543
+ }
544
+
545
+ write(ansi.moveTo(row + height - 1, col));
546
+ write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
547
+ write(ansi.reset);
548
+
549
+ // Title
550
+ const title = ` Preview: ${truncate(branch.name, width - 14)} `;
551
+ write(ansi.moveTo(row, col + 2));
552
+ write(ansi.cyan + ansi.bold + title + ansi.reset);
553
+
554
+ // Commits section
555
+ write(ansi.moveTo(row + 2, col + 2));
556
+ write(ansi.white + ansi.bold + 'Recent Commits:' + ansi.reset);
557
+
558
+ let contentRow = row + 3;
559
+ if (state.previewData.commits.length === 0) {
560
+ write(ansi.moveTo(contentRow, col + 3));
561
+ write(ansi.gray + '(no commits)' + ansi.reset);
562
+ contentRow++;
563
+ } else {
564
+ for (const commit of state.previewData.commits.slice(0, 5)) {
565
+ write(ansi.moveTo(contentRow, col + 3));
566
+ write(ansi.yellow + commit.hash + ansi.reset + ' ');
567
+ write(ansi.gray + truncate(commit.message, width - 14) + ansi.reset);
568
+ contentRow++;
569
+ }
570
+ }
571
+
572
+ // Files section
573
+ contentRow++;
574
+ write(ansi.moveTo(contentRow, col + 2));
575
+ write(ansi.white + ansi.bold + 'Files Changed vs HEAD:' + ansi.reset);
576
+ contentRow++;
577
+
578
+ if (state.previewData.filesChanged.length === 0) {
579
+ write(ansi.moveTo(contentRow, col + 3));
580
+ write(ansi.gray + '(no changes or same as current)' + ansi.reset);
581
+ } else {
582
+ for (const file of state.previewData.filesChanged.slice(0, 5)) {
583
+ write(ansi.moveTo(contentRow, col + 3));
584
+ write(ansi.green + '\u2022 ' + ansi.reset + truncate(file, width - 8));
585
+ contentRow++;
586
+ }
587
+ if (state.previewData.filesChanged.length > 5) {
588
+ write(ansi.moveTo(contentRow, col + 3));
589
+ write(ansi.gray + `... and ${state.previewData.filesChanged.length - 5} more` + ansi.reset);
590
+ }
591
+ }
592
+
593
+ // Instructions
594
+ write(ansi.moveTo(row + height - 2, col + Math.floor((width - 26) / 2)));
595
+ write(ansi.gray + 'Press [v] or [Esc] to close' + ansi.reset);
596
+ }
597
+
598
+ // ---------------------------------------------------------------------------
599
+ // renderHistory
600
+ // ---------------------------------------------------------------------------
601
+
602
+ /**
603
+ * Render the branch-switch history overlay.
604
+ *
605
+ * @param {object} state
606
+ * @param {function} write
607
+ */
608
+ function renderHistory(state, write) {
609
+ const width = Math.min(50, state.terminalWidth - 4);
610
+ const height = Math.min(state.switchHistory.length + 5, 15);
611
+ const col = Math.floor((state.terminalWidth - width) / 2);
612
+ const row = Math.floor((state.terminalHeight - height) / 2);
613
+
614
+ // Draw box
615
+ write(ansi.moveTo(row, col));
616
+ write(ansi.magenta + ansi.bold);
617
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
618
+
619
+ for (let i = 1; i < height - 1; i++) {
620
+ write(ansi.moveTo(row + i, col));
621
+ write(ansi.magenta + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.magenta + box.dVertical + ansi.reset);
622
+ }
623
+
624
+ write(ansi.moveTo(row + height - 1, col));
625
+ write(ansi.magenta + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
626
+ write(ansi.reset);
627
+
628
+ // Title
629
+ write(ansi.moveTo(row, col + 2));
630
+ write(ansi.magenta + ansi.bold + ' Switch History ' + ansi.reset);
631
+
632
+ // Content
633
+ if (state.switchHistory.length === 0) {
634
+ write(ansi.moveTo(row + 2, col + 3));
635
+ write(ansi.gray + 'No branch switches yet' + ansi.reset);
636
+ } else {
637
+ let contentRow = row + 2;
638
+ for (let i = 0; i < Math.min(state.switchHistory.length, height - 4); i++) {
639
+ const entry = state.switchHistory[i];
640
+ write(ansi.moveTo(contentRow, col + 3));
641
+ if (i === 0) {
642
+ write(ansi.yellow + '\u2192 ' + ansi.reset); // Most recent
643
+ } else {
644
+ write(ansi.gray + ' ' + ansi.reset);
645
+ }
646
+ write(truncate(entry.from, 15) + ansi.gray + ' \u2192 ' + ansi.reset);
647
+ write(ansi.cyan + truncate(entry.to, 15) + ansi.reset);
648
+ contentRow++;
649
+ }
650
+ }
651
+
652
+ // Instructions
653
+ write(ansi.moveTo(row + height - 2, col + 2));
654
+ write(ansi.gray + '[u] Undo last [h]/[Esc] Close' + ansi.reset);
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // renderLogView
659
+ // ---------------------------------------------------------------------------
660
+
661
+ /**
662
+ * Render the full-screen log viewer overlay with activity/server tabs.
663
+ *
664
+ * NOTE: The original bin/ version mutates `logScrollOffset` in place to
665
+ * clamp it. Since we receive state as a plain object the caller is
666
+ * responsible for clamping before calling this function, or the caller
667
+ * can read back the clamped value from the returned object if we decide
668
+ * to return one in the future. For now we treat `logScrollOffset` as
669
+ * already clamped.
670
+ *
671
+ * @param {object} state
672
+ * @param {function} write
673
+ */
674
+ function renderLogView(state, write) {
675
+ if (!state.logViewMode) return;
676
+
677
+ const width = Math.min(state.terminalWidth - 4, 100);
678
+ const height = Math.min(state.terminalHeight - 4, 30);
679
+ const col = Math.floor((state.terminalWidth - width) / 2);
680
+ const row = Math.floor((state.terminalHeight - height) / 2);
681
+
682
+ // Determine which log to display
683
+ const isServerTab = state.logViewTab === 'server';
684
+ const logData = isServerTab ? state.serverLogBuffer : state.activityLog;
685
+
686
+ // Draw box
687
+ write(ansi.moveTo(row, col));
688
+ write(ansi.yellow + ansi.bold);
689
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
690
+
691
+ for (let i = 1; i < height - 1; i++) {
692
+ write(ansi.moveTo(row + i, col));
693
+ write(ansi.yellow + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.yellow + box.dVertical + ansi.reset);
694
+ }
695
+
696
+ write(ansi.moveTo(row + height - 1, col));
697
+ write(ansi.yellow + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
698
+ write(ansi.reset);
699
+
700
+ // Title with tabs
701
+ const activityTab = state.logViewTab === 'activity'
702
+ ? ansi.bgWhite + ansi.black + ' 1:Activity ' + ansi.reset + ansi.yellow
703
+ : ansi.gray + ' 1:Activity ' + ansi.yellow;
704
+ const serverTab = state.logViewTab === 'server'
705
+ ? ansi.bgWhite + ansi.black + ' 2:Server ' + ansi.reset + ansi.yellow
706
+ : ansi.gray + ' 2:Server ' + ansi.yellow;
707
+
708
+ // Server status (only show on server tab)
709
+ let statusIndicator = '';
710
+ if (isServerTab && state.serverMode === 'command') {
711
+ const statusText = state.serverRunning ? ansi.green + 'RUNNING' : (state.serverCrashed ? ansi.red + 'CRASHED' : ansi.gray + 'STOPPED');
712
+ statusIndicator = ` [${statusText}${ansi.yellow}]`;
713
+ } else if (isServerTab && state.serverMode === 'static') {
714
+ statusIndicator = ansi.green + ' [STATIC]' + ansi.yellow;
715
+ }
716
+
717
+ write(ansi.moveTo(row, col + 2));
718
+ write(ansi.yellow + ansi.bold + ' ' + activityTab + ' ' + serverTab + statusIndicator + ' ' + ansi.reset);
719
+
720
+ // Content
721
+ const contentHeight = height - 4;
722
+ const maxScroll = Math.max(0, logData.length - contentHeight);
723
+ const logScrollOffset = Math.min(Math.max(0, state.logScrollOffset), maxScroll);
724
+
725
+ let contentRow = row + 2;
726
+
727
+ if (logData.length === 0) {
728
+ write(ansi.moveTo(contentRow, col + 2));
729
+ write(ansi.gray + (isServerTab ? 'No server output yet...' : 'No activity yet...') + ansi.reset);
730
+ } else if (isServerTab) {
731
+ // Server log: newest at bottom, scroll from bottom
732
+ const startIndex = Math.max(0, state.serverLogBuffer.length - contentHeight - logScrollOffset);
733
+ const endIndex = Math.min(state.serverLogBuffer.length, startIndex + contentHeight);
734
+
735
+ for (let i = startIndex; i < endIndex; i++) {
736
+ const entry = state.serverLogBuffer[i];
737
+ write(ansi.moveTo(contentRow, col + 2));
738
+ const lineText = truncate(entry.line, width - 4);
739
+ if (entry.isError) {
740
+ write(ansi.red + lineText + ansi.reset);
741
+ } else {
742
+ write(lineText);
743
+ }
744
+ contentRow++;
745
+ }
746
+ } else {
747
+ // Activity log: newest first, scroll from top
748
+ const startIndex = logScrollOffset;
749
+ const endIndex = Math.min(state.activityLog.length, startIndex + contentHeight);
750
+
751
+ for (let i = startIndex; i < endIndex; i++) {
752
+ const entry = state.activityLog[i];
753
+ write(ansi.moveTo(contentRow, col + 2));
754
+ write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
755
+ write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
756
+ write(truncate(entry.message, width - 18));
757
+ contentRow++;
758
+ }
759
+ }
760
+
761
+ // Scroll indicator
762
+ if (logData.length > contentHeight) {
763
+ const scrollPercent = isServerTab
764
+ ? Math.round((1 - logScrollOffset / maxScroll) * 100)
765
+ : Math.round((logScrollOffset / maxScroll) * 100);
766
+ write(ansi.moveTo(row, col + width - 10));
767
+ write(ansi.gray + ` ${scrollPercent}% ` + ansi.reset);
768
+ }
769
+
770
+ // Instructions
771
+ write(ansi.moveTo(row + height - 2, col + 2));
772
+ const restartHint = state.serverMode === 'command' ? '[R] Restart ' : '';
773
+ write(ansi.gray + '[1/2] Switch Tab [\u2191\u2193] Scroll ' + restartHint + '[l]/[Esc] Close' + ansi.reset);
774
+ }
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // renderInfo
778
+ // ---------------------------------------------------------------------------
779
+
780
+ /**
781
+ * Render the server/status info overlay.
782
+ *
783
+ * @param {object} state
784
+ * @param {function} write
785
+ */
786
+ function renderInfo(state, write) {
787
+ const width = Math.min(50, state.terminalWidth - 4);
788
+ const height = state.noServer ? 9 : 12;
789
+ const col = Math.floor((state.terminalWidth - width) / 2);
790
+ const row = Math.floor((state.terminalHeight - height) / 2);
791
+
792
+ // Draw box
793
+ write(ansi.moveTo(row, col));
794
+ write(ansi.cyan + ansi.bold);
795
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
796
+
797
+ for (let i = 1; i < height - 1; i++) {
798
+ write(ansi.moveTo(row + i, col));
799
+ write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
800
+ }
801
+
802
+ write(ansi.moveTo(row + height - 1, col));
803
+ write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
804
+ write(ansi.reset);
805
+
806
+ // Title
807
+ write(ansi.moveTo(row, col + 2));
808
+ write(ansi.cyan + ansi.bold + (state.noServer ? ' Status Info ' : ' Server Info ') + ansi.reset);
809
+
810
+ // Content
811
+ let contentRow = row + 2;
812
+
813
+ if (!state.noServer) {
814
+ write(ansi.moveTo(contentRow, col + 3));
815
+ write(ansi.white + ansi.bold + 'Dev Server' + ansi.reset);
816
+ contentRow++;
817
+
818
+ write(ansi.moveTo(contentRow, col + 3));
819
+ write(ansi.gray + 'URL: ' + ansi.reset + ansi.green + `http://localhost:${state.port}` + ansi.reset);
820
+ contentRow++;
821
+
822
+ write(ansi.moveTo(contentRow, col + 3));
823
+ write(ansi.gray + 'Port: ' + ansi.reset + ansi.yellow + state.port + ansi.reset);
824
+ contentRow++;
825
+
826
+ write(ansi.moveTo(contentRow, col + 3));
827
+ write(ansi.gray + 'Connected browsers: ' + ansi.reset + ansi.cyan + state.clientCount + ansi.reset);
828
+ contentRow++;
829
+
830
+ contentRow++;
831
+ }
832
+
833
+ write(ansi.moveTo(contentRow, col + 3));
834
+ write(ansi.white + ansi.bold + 'Git Polling' + ansi.reset);
835
+ contentRow++;
836
+
837
+ write(ansi.moveTo(contentRow, col + 3));
838
+ write(ansi.gray + 'Interval: ' + ansi.reset + `${state.adaptivePollInterval / 1000}s`);
839
+ contentRow++;
840
+
841
+ write(ansi.moveTo(contentRow, col + 3));
842
+ write(ansi.gray + 'Status: ' + ansi.reset + (state.isOffline ? ansi.red + 'Offline' : ansi.green + 'Online') + ansi.reset);
843
+ contentRow++;
844
+
845
+ if (state.noServer) {
846
+ write(ansi.moveTo(contentRow, col + 3));
847
+ write(ansi.gray + 'Mode: ' + ansi.reset + ansi.magenta + 'No-Server (branch monitor only)' + ansi.reset);
848
+ }
849
+
850
+ // Instructions
851
+ write(ansi.moveTo(row + height - 2, col + Math.floor((width - 20) / 2)));
852
+ write(ansi.gray + 'Press [i] or [Esc] to close' + ansi.reset);
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // renderActionModal
857
+ // ---------------------------------------------------------------------------
858
+
859
+ /**
860
+ * Render the branch-actions modal with PR/CI/Claude integration.
861
+ *
862
+ * @param {object} state
863
+ * @param {function} write
864
+ */
865
+ function renderActionModal(state, write) {
866
+ if (!state.actionMode || !state.actionData) return;
867
+
868
+ const { branch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, isClaudeBranch, platform, prLoaded } = state.actionData;
869
+
870
+ const width = Math.min(64, state.terminalWidth - 4);
871
+ const innerW = width - 6;
872
+
873
+ const platformLabel = platform === 'gitlab' ? 'GitLab' : platform === 'bitbucket' ? 'Bitbucket' : platform === 'azure' ? 'Azure DevOps' : 'GitHub';
874
+ const prLabel = platform === 'gitlab' ? 'MR' : 'PR';
875
+ const cliTool = platform === 'gitlab' ? 'glab' : 'gh';
876
+ const hasCli = platform === 'gitlab' ? hasGlab : hasGh;
877
+ const cliAuthed = platform === 'gitlab' ? glabAuthed : ghAuthed;
878
+ const cliReady = hasCli && cliAuthed;
879
+ const loading = state.actionLoading;
880
+
881
+ // Build actions list - ALL actions always shown, grayed out with reasons when unavailable
882
+ const actions = [];
883
+
884
+ // Open on web
885
+ actions.push({
886
+ key: 'b', label: `Open branch on ${platformLabel}`,
887
+ available: !!webUrl, reason: !webUrl ? 'Could not parse remote URL' : null,
888
+ });
889
+
890
+ // Claude session - always shown so users know it exists
891
+ actions.push({
892
+ key: 'c', label: 'Open Claude Code session',
893
+ available: !!sessionUrl,
894
+ reason: !isClaudeBranch ? 'Not a Claude branch' : !sessionUrl && !loading ? 'No session URL in commits' : null,
895
+ loading: isClaudeBranch && !sessionUrl && loading,
896
+ });
897
+
898
+ // PR: create or view depending on state
899
+ const prIsMerged = prInfo && (prInfo.state === 'MERGED' || prInfo.state === 'merged');
900
+ const prIsOpen = prInfo && (prInfo.state === 'OPEN' || prInfo.state === 'open');
901
+ if (prInfo) {
902
+ actions.push({ key: 'p', label: `View ${prLabel} #${prInfo.number}`, available: !!webUrl, reason: null });
903
+ } else {
904
+ actions.push({
905
+ key: 'p', label: `Create ${prLabel}`,
906
+ available: cliReady && prLoaded,
907
+ reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : null,
908
+ loading: cliReady && !prLoaded,
909
+ });
910
+ }
911
+
912
+ // Diff - opens on web, just needs a PR and webUrl
913
+ actions.push({
914
+ key: 'd', label: `View ${prLabel} diff on ${platformLabel}`,
915
+ available: !!prInfo && !!webUrl,
916
+ reason: !prInfo && prLoaded ? `No ${prLabel}` : !webUrl ? 'Could not parse remote URL' : null,
917
+ loading: !prLoaded && (cliReady || !!webUrl),
918
+ });
919
+
920
+ // Approve - disabled for merged PRs
921
+ actions.push({
922
+ key: 'a', label: `Approve ${prLabel}`,
923
+ available: !!prInfo && prIsOpen && cliReady,
924
+ reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
925
+ loading: cliReady && !prLoaded,
926
+ });
927
+
928
+ // Merge - disabled for already-merged PRs
929
+ actions.push({
930
+ key: 'm', label: `Merge ${prLabel} (squash)`,
931
+ available: !!prInfo && prIsOpen && cliReady,
932
+ reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
933
+ loading: cliReady && !prLoaded,
934
+ });
935
+
936
+ // CI
937
+ actions.push({
938
+ key: 'i', label: 'Check CI status',
939
+ available: cliReady && (!!prInfo || platform === 'gitlab'),
940
+ reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded && platform !== 'gitlab' ? `No open ${prLabel}` : null,
941
+ loading: cliReady && !prLoaded && platform !== 'gitlab',
942
+ });
943
+
944
+ // Calculate height
945
+ let contentLines = 0;
946
+ contentLines += 2; // spacing + branch name
947
+ contentLines += 1; // separator
948
+ contentLines += actions.length;
949
+ contentLines += 1; // separator
950
+
951
+ // Status info
952
+ const statusInfoLines = [];
953
+ if (prInfo) {
954
+ let prStatus = `${prLabel} #${prInfo.number}: ${truncate(prInfo.title, innerW - 20)}`;
955
+ const badges = [];
956
+ if (prIsMerged) badges.push('merged');
957
+ if (prInfo.approved) badges.push('approved');
958
+ if (prInfo.checksPass) badges.push('checks pass');
959
+ if (prInfo.checksFail) badges.push('checks fail');
960
+ if (badges.length) prStatus += ` [${badges.join(', ')}]`;
961
+ statusInfoLines.push({ color: prIsMerged ? 'magenta' : 'green', text: prStatus });
962
+ } else if (loading) {
963
+ statusInfoLines.push({ color: 'gray', text: `Loading ${prLabel} info...` });
964
+ } else if (cliReady) {
965
+ statusInfoLines.push({ color: 'gray', text: `No ${prLabel} for this branch` });
966
+ }
967
+
968
+ if (isClaudeBranch) {
969
+ if (sessionUrl) {
970
+ const shortSession = sessionUrl.replace('https://claude.ai/code/', '');
971
+ statusInfoLines.push({ color: 'magenta', text: `Session: ${truncate(shortSession, innerW - 10)}` });
972
+ } else if (!loading) {
973
+ statusInfoLines.push({ color: 'gray', text: 'Claude branch (no session URL in commits)' });
974
+ }
975
+ }
976
+
977
+ contentLines += statusInfoLines.length;
978
+
979
+ // Setup hints
980
+ const hints = [];
981
+ if (!hasCli) {
982
+ if (platform === 'gitlab') {
983
+ hints.push('Install glab: https://gitlab.com/gitlab-org/cli');
984
+ hints.push('Then run: glab auth login');
985
+ } else {
986
+ hints.push('Install gh: https://cli.github.com');
987
+ hints.push('Then run: gh auth login');
988
+ }
989
+ } else if (!cliAuthed) {
990
+ hints.push(`${cliTool} is installed but not authenticated`);
991
+ hints.push(`Run: ${cliTool} auth login`);
992
+ }
993
+
994
+ if (hints.length > 0) {
995
+ contentLines += 1;
996
+ contentLines += hints.length;
997
+ }
998
+
999
+ contentLines += 2; // blank + close instructions
1000
+
1001
+ const modalHeight = contentLines + 3;
1002
+ const modalCol = Math.floor((state.terminalWidth - width) / 2);
1003
+ const modalRow = Math.floor((state.terminalHeight - modalHeight) / 2);
1004
+
1005
+ // Draw box
1006
+ const borderColor = ansi.brightCyan;
1007
+ write(ansi.moveTo(modalRow, modalCol));
1008
+ write(borderColor + ansi.bold);
1009
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1010
+
1011
+ for (let i = 1; i < modalHeight - 1; i++) {
1012
+ write(ansi.moveTo(modalRow + i, modalCol));
1013
+ write(borderColor + box.dVertical + ansi.reset + ' '.repeat(width - 2) + borderColor + box.dVertical + ansi.reset);
1014
+ }
1015
+
1016
+ write(ansi.moveTo(modalRow + modalHeight - 1, modalCol));
1017
+ write(borderColor + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1018
+ write(ansi.reset);
1019
+
1020
+ // Title
1021
+ const title = ' Branch Actions ';
1022
+ write(ansi.moveTo(modalRow, modalCol + 2));
1023
+ write(borderColor + ansi.bold + title + ansi.reset);
1024
+
1025
+ let r = modalRow + 2;
1026
+
1027
+ // Branch name with type indicator
1028
+ write(ansi.moveTo(r, modalCol + 3));
1029
+ write(ansi.white + ansi.bold + truncate(branch.name, innerW - 10) + ansi.reset);
1030
+ if (isClaudeBranch) {
1031
+ write(ansi.magenta + ' [Claude]' + ansi.reset);
1032
+ }
1033
+ r++;
1034
+
1035
+ // Separator
1036
+ r++;
1037
+
1038
+ // Actions list - all always visible
1039
+ for (const action of actions) {
1040
+ write(ansi.moveTo(r, modalCol + 3));
1041
+ if (action.loading) {
1042
+ write(ansi.gray + '[' + action.key + '] ' + action.label + ' ' + ansi.dim + ansi.cyan + 'loading...' + ansi.reset);
1043
+ } else if (action.available) {
1044
+ write(ansi.brightCyan + '[' + action.key + ']' + ansi.reset + ' ' + action.label);
1045
+ } else {
1046
+ write(ansi.gray + '[' + action.key + '] ' + action.label);
1047
+ if (action.reason) {
1048
+ write(' ' + ansi.dim + ansi.yellow + action.reason + ansi.reset);
1049
+ }
1050
+ write(ansi.reset);
1051
+ }
1052
+ r++;
1053
+ }
1054
+
1055
+ // Separator
1056
+ r++;
1057
+
1058
+ // Status info
1059
+ for (const info of statusInfoLines) {
1060
+ write(ansi.moveTo(r, modalCol + 3));
1061
+ write(ansi[info.color] + truncate(info.text, innerW) + ansi.reset);
1062
+ r++;
1063
+ }
1064
+
1065
+ // Setup hints
1066
+ if (hints.length > 0) {
1067
+ r++;
1068
+ for (const hint of hints) {
1069
+ write(ansi.moveTo(r, modalCol + 3));
1070
+ write(ansi.yellow + truncate(hint, innerW) + ansi.reset);
1071
+ r++;
1072
+ }
1073
+ }
1074
+
1075
+ // Close instructions
1076
+ write(ansi.moveTo(modalRow + modalHeight - 2, modalCol + Math.floor((width - 18) / 2)));
1077
+ write(ansi.gray + 'Press [Esc] to close' + ansi.reset);
1078
+ }
1079
+
1080
+ // ---------------------------------------------------------------------------
1081
+ // renderStashConfirm
1082
+ // ---------------------------------------------------------------------------
1083
+
1084
+ /**
1085
+ * Render a stash confirmation dialog with selectable options.
1086
+ *
1087
+ * The dialog appears when the user tries to switch branches or pull with
1088
+ * uncommitted changes. It offers two options navigable with up/down arrows.
1089
+ *
1090
+ * State shape expected:
1091
+ * stashConfirmMode: boolean
1092
+ * stashConfirmSelectedIndex: number (0 or 1)
1093
+ * pendingDirtyOperationLabel: string (e.g. "switch to main", "pull")
1094
+ *
1095
+ * @param {object} state
1096
+ * @param {function} write
1097
+ */
1098
+ function renderStashConfirm(state, write) {
1099
+ if (!state.stashConfirmMode) return;
1100
+
1101
+ const options = [
1102
+ 'Stash changes and continue',
1103
+ "No, I'll handle it myself",
1104
+ ];
1105
+ const selectedIdx = state.stashConfirmSelectedIndex || 0;
1106
+
1107
+ const title = 'Uncommitted Changes';
1108
+ const message = state.pendingDirtyOperationLabel
1109
+ ? `You have uncommitted changes that conflict with: ${state.pendingDirtyOperationLabel}`
1110
+ : 'You have uncommitted changes in your working directory.';
1111
+
1112
+ const width = Math.min(60, state.terminalWidth - 4);
1113
+ const col = Math.floor((state.terminalWidth - width) / 2);
1114
+ const row = 2;
1115
+
1116
+ // Build content lines
1117
+ const lines = [];
1118
+ lines.push(title);
1119
+ lines.push('');
1120
+
1121
+ // Word wrap the message
1122
+ const msgWords = message.split(' ');
1123
+ let currentLine = '';
1124
+ for (const word of msgWords) {
1125
+ if ((currentLine + ' ' + word).length > width - 6) {
1126
+ lines.push(currentLine.trim());
1127
+ currentLine = word;
1128
+ } else {
1129
+ currentLine += (currentLine ? ' ' : '') + word;
1130
+ }
1131
+ }
1132
+ if (currentLine) lines.push(currentLine.trim());
1133
+
1134
+ lines.push('');
1135
+
1136
+ // Option lines (we'll render these specially)
1137
+ const optionStartIdx = lines.length;
1138
+ for (const opt of options) {
1139
+ lines.push(opt);
1140
+ }
1141
+
1142
+ lines.push('');
1143
+ lines.push('[S] Stash [Esc] Cancel');
1144
+
1145
+ const height = lines.length + 2;
1146
+
1147
+ // Draw yellow/amber box
1148
+ write(ansi.moveTo(row, col));
1149
+ write(ansi.yellow + ansi.bold);
1150
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1151
+
1152
+ for (let i = 1; i < height - 1; i++) {
1153
+ write(ansi.moveTo(row + i, col));
1154
+ write(ansi.yellow + box.dVertical + ansi.reset + ' ' + ' '.repeat(width - 6) + ' ' + ansi.yellow + box.dVertical + ansi.reset);
1155
+ }
1156
+
1157
+ write(ansi.moveTo(row + height - 1, col));
1158
+ write(ansi.yellow + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1159
+ write(ansi.reset);
1160
+
1161
+ // Render content
1162
+ let contentRow = row + 1;
1163
+ for (let i = 0; i < lines.length; i++) {
1164
+ const line = lines[i];
1165
+ write(ansi.moveTo(contentRow, col + 3));
1166
+
1167
+ if (i === 0) {
1168
+ // Title — centered, bold yellow
1169
+ const titlePadding = Math.floor((width - 6 - line.length) / 2);
1170
+ write(' '.repeat(titlePadding) + ansi.yellow + ansi.bold + line + ansi.reset + ' '.repeat(width - 6 - titlePadding - line.length));
1171
+ } else if (i >= optionStartIdx && i < optionStartIdx + options.length) {
1172
+ // Selectable option
1173
+ const optIdx = i - optionStartIdx;
1174
+ const isSelected = optIdx === selectedIdx;
1175
+ const prefix = isSelected ? '\u25b8 ' : ' ';
1176
+ const optText = prefix + line;
1177
+ if (isSelected) {
1178
+ write(ansi.bold + ansi.cyan + padRight(optText, width - 6) + ansi.reset);
1179
+ } else {
1180
+ write(ansi.gray + padRight(optText, width - 6) + ansi.reset);
1181
+ }
1182
+ } else if (line === '[S] Stash [Esc] Cancel') {
1183
+ // Keyboard hints — centered, dim
1184
+ const lPadding = Math.floor((width - 6 - line.length) / 2);
1185
+ write(ansi.dim + ' '.repeat(lPadding) + line + ' '.repeat(width - 6 - lPadding - line.length) + ansi.reset);
1186
+ } else {
1187
+ // Regular content
1188
+ write(padRight(line, width - 6));
1189
+ }
1190
+ contentRow++;
1191
+ }
1192
+ }
1193
+
1194
+ // ---------------------------------------------------------------------------
1195
+ // renderCleanupConfirm
1196
+ // ---------------------------------------------------------------------------
1197
+
1198
+ /**
1199
+ * Render the cleanup confirmation modal.
1200
+ * Lists branches whose remote tracking branch is gone and asks for confirmation.
1201
+ *
1202
+ * @param {object} state
1203
+ * @param {function} write
1204
+ */
1205
+ function renderCleanupConfirm(state, write) {
1206
+ if (!state.cleanupConfirmMode || !state.cleanupBranches) return;
1207
+
1208
+ const branches = state.cleanupBranches;
1209
+ const width = Math.min(60, state.terminalWidth - 4);
1210
+ const innerW = width - 6;
1211
+
1212
+ // Build content lines
1213
+ const lines = [];
1214
+ lines.push('Cleanup Stale Branches');
1215
+ lines.push('');
1216
+
1217
+ if (branches.length === 0) {
1218
+ lines.push('No stale branches found.');
1219
+ lines.push('All local branches have active remotes.');
1220
+ } else {
1221
+ lines.push(`${branches.length} branch${branches.length === 1 ? '' : 'es'} with deleted remote:`);
1222
+ lines.push('');
1223
+
1224
+ const maxShown = Math.min(branches.length, 10);
1225
+ for (let i = 0; i < maxShown; i++) {
1226
+ lines.push('\u2717 ' + truncate(branches[i], innerW - 4));
1227
+ }
1228
+ if (branches.length > maxShown) {
1229
+ lines.push(` ... and ${branches.length - maxShown} more`);
1230
+ }
1231
+ }
1232
+
1233
+ lines.push('');
1234
+
1235
+ // Options
1236
+ const optionStartIdx = lines.length;
1237
+ const options = branches.length > 0
1238
+ ? ['Delete all (safe — merged only)', 'Force delete all (including unmerged)', 'Cancel']
1239
+ : ['Close'];
1240
+ for (const opt of options) {
1241
+ lines.push(opt);
1242
+ }
1243
+
1244
+ lines.push('');
1245
+ if (branches.length > 0) {
1246
+ lines.push('[Enter] Select [Esc] Cancel');
1247
+ } else {
1248
+ lines.push('[Esc] Close');
1249
+ }
1250
+
1251
+ const height = lines.length + 2;
1252
+ const col = Math.floor((state.terminalWidth - width) / 2);
1253
+ const row = Math.floor((state.terminalHeight - height) / 2);
1254
+ const selectedIdx = state.cleanupSelectedIndex || 0;
1255
+
1256
+ // Draw red/amber box
1257
+ const borderColor = ansi.red;
1258
+ write(ansi.moveTo(row, col));
1259
+ write(borderColor + ansi.bold);
1260
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1261
+
1262
+ for (let i = 1; i < height - 1; i++) {
1263
+ write(ansi.moveTo(row + i, col));
1264
+ write(borderColor + box.dVertical + ansi.reset + ' ' + ' '.repeat(width - 6) + ' ' + borderColor + box.dVertical + ansi.reset);
1265
+ }
1266
+
1267
+ write(ansi.moveTo(row + height - 1, col));
1268
+ write(borderColor + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1269
+ write(ansi.reset);
1270
+
1271
+ // Render content
1272
+ let contentRow = row + 1;
1273
+ for (let i = 0; i < lines.length; i++) {
1274
+ const line = lines[i];
1275
+ write(ansi.moveTo(contentRow, col + 3));
1276
+
1277
+ if (i === 0) {
1278
+ // Title — centered, bold red
1279
+ const titlePadding = Math.floor((width - 6 - line.length) / 2);
1280
+ write(' '.repeat(titlePadding) + ansi.red + ansi.bold + line + ansi.reset + ' '.repeat(width - 6 - titlePadding - line.length));
1281
+ } else if (i >= optionStartIdx && i < optionStartIdx + options.length) {
1282
+ // Selectable option
1283
+ const optIdx = i - optionStartIdx;
1284
+ const isSelected = optIdx === selectedIdx;
1285
+ const prefix = isSelected ? '\u25b8 ' : ' ';
1286
+ const optText = prefix + line;
1287
+ if (isSelected) {
1288
+ write(ansi.bold + ansi.cyan + padRight(optText, width - 6) + ansi.reset);
1289
+ } else {
1290
+ write(ansi.gray + padRight(optText, width - 6) + ansi.reset);
1291
+ }
1292
+ } else if (line.startsWith('[Enter]') || line.startsWith('[Esc]')) {
1293
+ // Keyboard hints — centered, dim
1294
+ const lPadding = Math.floor((width - 6 - line.length) / 2);
1295
+ write(ansi.dim + ' '.repeat(lPadding) + line + ' '.repeat(width - 6 - lPadding - line.length) + ansi.reset);
1296
+ } else if (line.startsWith('\u2717 ')) {
1297
+ // Branch name — red cross
1298
+ write(ansi.red + '\u2717 ' + ansi.reset + ansi.gray + truncate(line.slice(2), innerW - 2) + ansi.reset + ' '.repeat(Math.max(0, width - 6 - line.length)));
1299
+ } else {
1300
+ // Regular content
1301
+ write(padRight(line, width - 6));
1302
+ }
1303
+ contentRow++;
1304
+ }
1305
+ }
1306
+
1307
+ // ---------------------------------------------------------------------------
1308
+ // Exports
1309
+ // ---------------------------------------------------------------------------
1310
+
1311
+ module.exports = {
1312
+ renderHeader,
1313
+ renderBranchList,
1314
+ renderActivityLog,
1315
+ renderCasinoStats,
1316
+ renderFooter,
1317
+ renderFlash,
1318
+ renderErrorToast,
1319
+ renderPreview,
1320
+ renderHistory,
1321
+ renderLogView,
1322
+ renderInfo,
1323
+ renderActionModal,
1324
+ renderStashConfirm,
1325
+ renderCleanupConfirm,
1326
+ };