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