git-watchtower 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/git-watchtower.js +489 -1394
  2. package/package.json +1 -1
@@ -86,6 +86,10 @@ const { isGitAvailable: checkGitAvailable } = require('../src/git/commands');
86
86
  const { getDefaultConfig, migrateConfig } = require('../src/config/schema');
87
87
  const { getConfigPath, loadConfig: loadConfigFile, saveConfig: saveConfigFile, CONFIG_FILE_NAME } = require('../src/config/loader');
88
88
 
89
+ // Centralized state store
90
+ const { Store } = require('../src/state/store');
91
+ const store = new Store();
92
+
89
93
  const PROJECT_ROOT = process.cwd();
90
94
 
91
95
  function loadConfig() {
@@ -363,19 +367,8 @@ let AUTO_PULL = true;
363
367
  const MAX_LOG_ENTRIES = 10;
364
368
  const MAX_SERVER_LOG_LINES = 500;
365
369
 
366
- // Dynamic settings
367
- let visibleBranchCount = 7;
368
- let soundEnabled = true;
369
- let casinoModeEnabled = false;
370
-
371
370
  // Server process management (for command mode)
372
371
  let serverProcess = null;
373
- let serverLogBuffer = []; // In-memory log buffer
374
- let serverRunning = false;
375
- let serverCrashed = false;
376
- let logViewMode = false; // Viewing logs modal
377
- let logViewTab = 'server'; // 'activity' or 'server'
378
- let logScrollOffset = 0; // Scroll position in log view
379
372
 
380
373
  function applyConfig(config) {
381
374
  // Server settings
@@ -391,28 +384,35 @@ function applyConfig(config) {
391
384
  AUTO_PULL = config.autoPull !== false;
392
385
  GIT_POLL_INTERVAL = config.gitPollInterval || parseInt(process.env.GIT_POLL_INTERVAL, 10) || 5000;
393
386
 
394
- // UI settings
395
- visibleBranchCount = config.visibleBranches || 7;
396
- soundEnabled = config.soundEnabled !== false;
387
+ // UI settings via store
388
+ const casinoEnabled = config.casinoMode === true;
389
+ store.setState({
390
+ visibleBranchCount: config.visibleBranches || 7,
391
+ soundEnabled: config.soundEnabled !== false,
392
+ casinoModeEnabled: casinoEnabled,
393
+ serverMode: SERVER_MODE,
394
+ noServer: NO_SERVER,
395
+ port: PORT,
396
+ maxLogEntries: MAX_LOG_ENTRIES,
397
+ projectName: path.basename(PROJECT_ROOT),
398
+ adaptivePollInterval: GIT_POLL_INTERVAL,
399
+ });
397
400
 
398
401
  // Casino mode
399
- casinoModeEnabled = config.casinoMode === true;
400
- if (casinoModeEnabled) {
402
+ if (casinoEnabled) {
401
403
  casino.enable();
402
404
  }
403
405
  }
404
406
 
405
407
  // Server log management
406
408
  function addServerLog(line, isError = false) {
407
- const timestamp = new Date().toLocaleTimeString();
408
- serverLogBuffer.push({ timestamp, line, isError });
409
- if (serverLogBuffer.length > MAX_SERVER_LOG_LINES) {
410
- serverLogBuffer.shift();
411
- }
409
+ const entry = { timestamp: new Date().toLocaleTimeString(), line, isError };
410
+ const serverLogBuffer = [...store.get('serverLogBuffer'), entry].slice(-MAX_SERVER_LOG_LINES);
411
+ store.setState({ serverLogBuffer });
412
412
  }
413
413
 
414
414
  function clearServerLog() {
415
- serverLogBuffer = [];
415
+ store.setState({ serverLogBuffer: [] });
416
416
  }
417
417
 
418
418
  // openInBrowser imported from src/utils/browser.js
@@ -609,8 +609,7 @@ function startServerProcess() {
609
609
  }
610
610
 
611
611
  clearServerLog();
612
- serverCrashed = false;
613
- serverRunning = false;
612
+ store.setState({ serverCrashed: false, serverRunning: false });
614
613
 
615
614
  addLog(`Starting: ${SERVER_COMMAND}`, 'update');
616
615
  addServerLog(`$ ${SERVER_COMMAND}`);
@@ -631,7 +630,7 @@ function startServerProcess() {
631
630
 
632
631
  try {
633
632
  serverProcess = spawn(cmd, args, spawnOptions);
634
- serverRunning = true;
633
+ store.setState({ serverRunning: true });
635
634
 
636
635
  serverProcess.stdout.on('data', (data) => {
637
636
  const lines = data.toString().split('\n').filter(Boolean);
@@ -644,17 +643,16 @@ function startServerProcess() {
644
643
  });
645
644
 
646
645
  serverProcess.on('error', (err) => {
647
- serverRunning = false;
648
- serverCrashed = true;
646
+ store.setState({ serverRunning: false, serverCrashed: true });
649
647
  addServerLog(`Error: ${err.message}`, true);
650
648
  addLog(`Server error: ${err.message}`, 'error');
651
649
  render();
652
650
  });
653
651
 
654
652
  serverProcess.on('close', (code) => {
655
- serverRunning = false;
653
+ store.setState({ serverRunning: false });
656
654
  if (code !== 0 && code !== null) {
657
- serverCrashed = true;
655
+ store.setState({ serverCrashed: true });
658
656
  addServerLog(`Process exited with code ${code}`, true);
659
657
  addLog(`Server exited with code ${code}`, 'error');
660
658
  } else {
@@ -667,7 +665,7 @@ function startServerProcess() {
667
665
 
668
666
  addLog(`Server started (pid: ${serverProcess.pid})`, 'success');
669
667
  } catch (err) {
670
- serverCrashed = true;
668
+ store.setState({ serverCrashed: true });
671
669
  addServerLog(`Failed to start: ${err.message}`, true);
672
670
  addLog(`Failed to start server: ${err.message}`, 'error');
673
671
  }
@@ -692,7 +690,7 @@ function stopServerProcess() {
692
690
  }
693
691
 
694
692
  serverProcess = null;
695
- serverRunning = false;
693
+ store.setState({ serverRunning: false });
696
694
  }
697
695
 
698
696
  function restartServerProcess() {
@@ -705,63 +703,41 @@ function restartServerProcess() {
705
703
  }
706
704
 
707
705
  // Network and polling state
708
- let consecutiveNetworkFailures = 0;
709
- let isOffline = false;
710
- let lastFetchDuration = 0;
711
706
  let slowFetchWarningShown = false;
712
707
  let verySlowFetchWarningShown = false;
713
- let adaptivePollInterval = GIT_POLL_INTERVAL;
714
708
  let pollIntervalId = null;
715
709
 
716
- // Git state
717
- let isDetachedHead = false;
718
- let hasMergeConflict = false;
719
-
720
710
  // ANSI escape codes and box drawing imported from src/ui/ansi.js
721
- const { ansi, box, truncate, sparkline: uiSparkline, visibleLength, stripAnsi } = require('../src/ui/ansi');
711
+ const { ansi, box, truncate, sparkline: uiSparkline, visibleLength, stripAnsi, padRight, padLeft, getMaxBranchesForScreen: calcMaxBranches, drawBox: renderBox, clearArea: renderClearArea } = require('../src/ui/ansi');
722
712
 
723
713
  // Error detection utilities imported from src/utils/errors.js
724
- const { ErrorHandler } = require('../src/utils/errors');
714
+ const { ErrorHandler, isAuthError, isMergeConflict, isNetworkError } = require('../src/utils/errors');
725
715
 
726
- // State
727
- let branches = [];
728
- let selectedIndex = 0;
729
- let selectedBranchName = null; // Track selection by name, not just index
730
- let currentBranch = null;
716
+ // Keyboard handling utilities imported from src/ui/keybindings.js
717
+ const { filterBranches } = require('../src/ui/keybindings');
718
+
719
+ // Extracted renderer and action handlers
720
+ const renderer = require('../src/ui/renderer');
721
+ const actions = require('../src/ui/actions');
722
+
723
+ // Diff stats parsing and stash imported from src/git/commands.js
724
+ const { parseDiffStats, stash: gitStash, stashPop: gitStashPop } = require('../src/git/commands');
725
+
726
+ // State (non-store globals)
731
727
  let previousBranchStates = new Map(); // branch name -> commit hash
732
728
  let knownBranchNames = new Set(); // Track known branches to detect NEW ones
733
- let isPolling = false;
734
- let pollingStatus = 'idle';
735
- let terminalWidth = process.stdout.columns || 80;
736
- let terminalHeight = process.stdout.rows || 24;
737
729
 
738
730
  // SSE clients for live reload
739
731
  const clients = new Set();
740
732
 
741
- // Activity log entries
742
- const activityLog = [];
743
-
744
- // Flash state
745
- let flashMessage = null;
733
+ // Flash/error toast timers
746
734
  let flashTimeout = null;
747
-
748
- // Error toast state (more prominent than activity log)
749
- let errorToast = null;
750
735
  let errorToastTimeout = null;
751
736
 
752
- // Preview pane state
753
- let previewMode = false;
754
- let previewData = null;
755
-
756
- // Search/filter state
757
- let searchMode = false;
758
- let searchQuery = '';
759
- let filteredBranches = null;
760
-
761
- // Branch action modal state
762
- let actionMode = false;
763
- let actionData = null; // { branch, sessionUrl, prInfo, hasGh, hasGlab, webUrl, isClaudeBranch, ... }
764
- let actionLoading = false; // true while PR info is being fetched asynchronously
737
+ // Tracks the operation that failed due to dirty working directory.
738
+ // When set, pressing 'S' will stash changes and retry the operation.
739
+ // Shape: { type: 'switch', branch: string } | { type: 'pull' } | null
740
+ let pendingDirtyOperation = null;
765
741
 
766
742
  // Cached environment info (populated once at startup, doesn't change during session)
767
743
  let cachedEnv = null; // { hasGh, hasGlab, ghAuthed, glabAuthed, webUrlBase, platform }
@@ -770,21 +746,15 @@ let cachedEnv = null; // { hasGh, hasGlab, ghAuthed, glabAuthed, webUrlBase, pla
770
746
  // Invalidated when the branch's commit hash changes
771
747
  const prInfoCache = new Map();
772
748
 
773
- // Bulk PR status map: Map<branchName, { state: 'OPEN'|'MERGED'|'CLOSED', number, title }>
774
- // Updated in background every PR_STATUS_POLL_INTERVAL ms
775
- let branchPrStatusMap = new Map();
776
749
  let lastPrStatusFetch = 0;
777
750
  const PR_STATUS_POLL_INTERVAL = 60 * 1000; // 60 seconds
778
751
  let prStatusFetchInFlight = false;
779
752
 
780
753
  // BASE_BRANCH_RE and isBaseBranch imported from src/git/pr.js
781
754
 
782
- // Session history for undo
783
- const switchHistory = [];
784
755
  const MAX_HISTORY = 20;
785
756
 
786
- // Sparkline cache (conservative - only update on manual fetch)
787
- const sparklineCache = new Map(); // branch name -> sparkline string
757
+ // Sparkline timing
788
758
  let lastSparklineUpdate = 0;
789
759
  const SPARKLINE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
790
760
 
@@ -847,18 +817,7 @@ function execAsync(command, options = {}) {
847
817
  async function getDiffStats(fromCommit, toCommit = 'HEAD') {
848
818
  try {
849
819
  const { stdout } = await execAsync(`git diff --stat ${fromCommit}..${toCommit}`);
850
- // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
851
- const match = stdout.match(/(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)/);
852
- if (match) {
853
- return { added: parseInt(match[1], 10), deleted: parseInt(match[2], 10) };
854
- }
855
- // Try to match just insertions or just deletions
856
- const insertMatch = stdout.match(/(\d+) insertions?\(\+\)/);
857
- const deleteMatch = stdout.match(/(\d+) deletions?\(-\)/);
858
- return {
859
- added: insertMatch ? parseInt(insertMatch[1], 10) : 0,
860
- deleted: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
861
- };
820
+ return parseDiffStats(stdout);
862
821
  } catch (e) {
863
822
  return { added: 0, deleted: 0 };
864
823
  }
@@ -868,21 +827,10 @@ async function getDiffStats(fromCommit, toCommit = 'HEAD') {
868
827
 
869
828
  // truncate imported from src/ui/ansi.js
870
829
 
871
- function padRight(str, len) {
872
- if (str.length >= len) return str.substring(0, len);
873
- return str + ' '.repeat(len - str.length);
874
- }
830
+ // padRight, padLeft imported from src/ui/ansi.js
875
831
 
876
832
  function getMaxBranchesForScreen() {
877
- // Calculate max branches that fit: header(2) + branch box + log box(~12) + footer(2)
878
- // Each branch takes 2 rows, plus 4 for box borders
879
- const availableHeight = terminalHeight - 2 - MAX_LOG_ENTRIES - 5 - 2;
880
- return Math.max(1, Math.floor(availableHeight / 2));
881
- }
882
-
883
- function padLeft(str, len) {
884
- if (str.length >= len) return str.substring(0, len);
885
- return ' '.repeat(len - str.length) + str;
833
+ return calcMaxBranches(store.get('terminalHeight'), MAX_LOG_ENTRIES);
886
834
  }
887
835
 
888
836
  // Casino mode funny messages
@@ -927,12 +875,16 @@ function getCasinoMessage(type) {
927
875
  }
928
876
 
929
877
  function addLog(message, type = 'info') {
930
- const timestamp = new Date().toLocaleTimeString();
931
878
  const icons = { info: '○', success: '✓', warning: '●', error: '✗', update: '⟳' };
932
879
  const colors = { info: 'white', success: 'green', warning: 'yellow', error: 'red', update: 'cyan' };
933
-
934
- activityLog.unshift({ timestamp, message, icon: icons[type] || '○', color: colors[type] || 'white' });
935
- if (activityLog.length > MAX_LOG_ENTRIES) activityLog.pop();
880
+ const entry = {
881
+ message, type,
882
+ timestamp: new Date().toLocaleTimeString(),
883
+ icon: icons[type] || '○',
884
+ color: colors[type] || 'white',
885
+ };
886
+ const activityLog = [entry, ...store.get('activityLog')].slice(0, MAX_LOG_ENTRIES);
887
+ store.setState({ activityLog });
936
888
  }
937
889
 
938
890
  // generateSparkline uses uiSparkline from src/ui/ansi.js
@@ -943,7 +895,7 @@ function generateSparkline(commitCounts) {
943
895
 
944
896
  async function getBranchSparkline(branchName) {
945
897
  // Check cache first
946
- const cached = sparklineCache.get(branchName);
898
+ const cached = store.get('sparklineCache').get(branchName);
947
899
  if (cached && (Date.now() - lastSparklineUpdate) < SPARKLINE_CACHE_TTL) {
948
900
  return cached;
949
901
  }
@@ -957,7 +909,8 @@ async function refreshAllSparklines() {
957
909
  }
958
910
 
959
911
  try {
960
- for (const branch of branches.slice(0, 20)) { // Limit to top 20
912
+ const currentBranches = store.get('branches');
913
+ for (const branch of currentBranches.slice(0, 20)) { // Limit to top 20
961
914
  if (branch.isDeleted) continue;
962
915
 
963
916
  // Get commit counts for last 7 days
@@ -983,7 +936,7 @@ async function refreshAllSparklines() {
983
936
  }
984
937
 
985
938
  const counts = Array.from(dayCounts.values());
986
- sparklineCache.set(branch.name, generateSparkline(counts));
939
+ store.get('sparklineCache').set(branch.name, generateSparkline(counts));
987
940
  }
988
941
  lastSparklineUpdate = now;
989
942
  } catch (e) {
@@ -1022,7 +975,7 @@ async function getPreviewData(branchName) {
1022
975
 
1023
976
  // playSound delegates to extracted src/utils/sound.js
1024
977
  function playSound() {
1025
- if (!soundEnabled) return;
978
+ if (!store.get('soundEnabled')) return;
1026
979
  playSoundEffect({ cwd: PROJECT_ROOT });
1027
980
  }
1028
981
 
@@ -1047,335 +1000,32 @@ function restoreTerminalTitle() {
1047
1000
  }
1048
1001
 
1049
1002
  function updateTerminalSize() {
1050
- terminalWidth = process.stdout.columns || 80;
1051
- terminalHeight = process.stdout.rows || 24;
1003
+ store.setState({
1004
+ terminalWidth: process.stdout.columns || 80,
1005
+ terminalHeight: process.stdout.rows || 24,
1006
+ });
1052
1007
  }
1053
1008
 
1054
1009
  function drawBox(row, col, width, height, title = '', titleColor = ansi.cyan) {
1055
- // Top border
1056
- write(ansi.moveTo(row, col));
1057
- write(ansi.gray + box.topLeft + box.horizontal.repeat(width - 2) + box.topRight + ansi.reset);
1058
-
1059
- // Title
1060
- if (title) {
1061
- write(ansi.moveTo(row, col + 2));
1062
- write(ansi.gray + ' ' + titleColor + title + ansi.gray + ' ' + ansi.reset);
1063
- }
1064
-
1065
- // Sides
1066
- for (let i = 1; i < height - 1; i++) {
1067
- write(ansi.moveTo(row + i, col));
1068
- write(ansi.gray + box.vertical + ansi.reset);
1069
- write(ansi.moveTo(row + i, col + width - 1));
1070
- write(ansi.gray + box.vertical + ansi.reset);
1071
- }
1072
-
1073
- // Bottom border
1074
- write(ansi.moveTo(row + height - 1, col));
1075
- write(ansi.gray + box.bottomLeft + box.horizontal.repeat(width - 2) + box.bottomRight + ansi.reset);
1010
+ write(renderBox(row, col, width, height, title, titleColor));
1076
1011
  }
1077
1012
 
1078
1013
  function clearArea(row, col, width, height) {
1079
- for (let i = 0; i < height; i++) {
1080
- write(ansi.moveTo(row + i, col));
1081
- write(' '.repeat(width));
1082
- }
1083
- }
1084
-
1085
- function renderHeader() {
1086
- const width = terminalWidth;
1087
- // Header row: 1 normally, 2 when casino mode (row 1 is marquee)
1088
- const headerRow = casinoModeEnabled ? 2 : 1;
1089
-
1090
- let statusIcon = { idle: ansi.green + '●', fetching: ansi.yellow + '⟳', error: ansi.red + '●' }[pollingStatus];
1091
-
1092
- // Override status for special states
1093
- if (isOffline) {
1094
- statusIcon = ansi.red + '⊘';
1095
- }
1096
-
1097
- const soundIcon = soundEnabled ? ansi.green + '🔔' : ansi.gray + '🔕';
1098
- const projectName = path.basename(PROJECT_ROOT);
1099
-
1100
- write(ansi.moveTo(headerRow, 1));
1101
- write(ansi.bgBlue + ansi.white + ansi.bold);
1102
-
1103
- // Left side: Title + separator + project name
1104
- const leftContent = ` 🏰 Git Watchtower ${ansi.dim}│${ansi.bold} ${projectName}`;
1105
- const leftVisibleLen = 21 + projectName.length; // " 🏰 Git Watchtower │ " + projectName
1106
-
1107
- write(leftContent);
1108
-
1109
- // Warning badges (center area)
1110
- let badges = '';
1111
- let badgesVisibleLen = 0;
1112
-
1113
- // Casino mode slot display moved to its own row below header (row 3)
1114
-
1115
- if (SERVER_MODE === 'command' && serverCrashed) {
1116
- const label = ' CRASHED ';
1117
- badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
1118
- badgesVisibleLen += 1 + label.length;
1119
- }
1120
- if (isOffline) {
1121
- const label = ' OFFLINE ';
1122
- badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
1123
- badgesVisibleLen += 1 + label.length;
1124
- }
1125
- if (isDetachedHead) {
1126
- const label = ' DETACHED HEAD ';
1127
- badges += ' ' + ansi.bgYellow + ansi.black + label + ansi.bgBlue + ansi.white;
1128
- badgesVisibleLen += 1 + label.length;
1129
- }
1130
- if (hasMergeConflict) {
1131
- const label = ' MERGE CONFLICT ';
1132
- badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
1133
- badgesVisibleLen += 1 + label.length;
1134
- }
1135
-
1136
- write(badges);
1137
-
1138
- // Right side: Server mode + URL + status icons
1139
- let modeLabel = '';
1140
- let modeBadge = '';
1141
- if (SERVER_MODE === 'static') {
1142
- modeLabel = ' STATIC ';
1143
- modeBadge = ansi.bgCyan + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
1144
- } else if (SERVER_MODE === 'command') {
1145
- modeLabel = ' COMMAND ';
1146
- modeBadge = ansi.bgGreen + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
1147
- } else {
1148
- modeLabel = ' MONITOR ';
1149
- modeBadge = ansi.bgMagenta + ansi.white + modeLabel + ansi.bgBlue + ansi.white;
1150
- }
1151
-
1152
- let serverInfo = '';
1153
- let serverInfoVisible = '';
1154
- if (SERVER_MODE === 'none') {
1155
- serverInfoVisible = '';
1156
- } else {
1157
- const statusDot = serverRunning ? ansi.green + '●' : (serverCrashed ? ansi.red + '●' : ansi.gray + '○');
1158
- serverInfoVisible = `localhost:${PORT} `;
1159
- serverInfo = statusDot + ansi.white + ` localhost:${PORT} `;
1160
- }
1161
-
1162
- const rightContent = `${modeBadge} ${serverInfo}${statusIcon}${ansi.bgBlue} ${soundIcon}${ansi.bgBlue} `;
1163
- const rightVisibleLen = modeLabel.length + 1 + serverInfoVisible.length + 5; // mode + space + serverInfo + "● 🔔 "
1164
-
1165
- // Calculate padding to fill full width
1166
- const usedSpace = leftVisibleLen + badgesVisibleLen + rightVisibleLen;
1167
- const padding = Math.max(1, width - usedSpace);
1168
- write(' '.repeat(padding));
1169
- write(rightContent);
1170
- write(ansi.reset);
1171
- }
1172
-
1173
- function renderBranchList() {
1174
- // Start row: 3 normally, 4 when casino mode (row 1 is marquee, row 2 is header)
1175
- const startRow = casinoModeEnabled ? 4 : 3;
1176
- const boxWidth = terminalWidth;
1177
- const contentWidth = boxWidth - 4; // Space between borders
1178
- const height = Math.min(visibleBranchCount * 2 + 4, Math.floor(terminalHeight * 0.5));
1179
-
1180
- // Determine which branches to show (filtered or all)
1181
- const displayBranches = filteredBranches !== null ? filteredBranches : branches;
1182
- const boxTitle = searchMode
1183
- ? `BRANCHES (/${searchQuery}_)`
1184
- : 'ACTIVE BRANCHES';
1185
-
1186
- drawBox(startRow, 1, boxWidth, height, boxTitle, ansi.cyan);
1187
-
1188
- // Clear content area first (fixes border gaps)
1189
- for (let i = 1; i < height - 1; i++) {
1190
- write(ansi.moveTo(startRow + i, 2));
1191
- write(' '.repeat(contentWidth + 2));
1192
- }
1193
-
1194
- // Header line
1195
- write(ansi.moveTo(startRow + 1, 2));
1196
- write(ansi.gray + '─'.repeat(contentWidth + 2) + ansi.reset);
1197
-
1198
- if (displayBranches.length === 0) {
1199
- write(ansi.moveTo(startRow + 3, 4));
1200
- if (searchMode && searchQuery) {
1201
- write(ansi.gray + `No branches matching "${searchQuery}"` + ansi.reset);
1202
- } else {
1203
- write(ansi.gray + "No branches found. Press 'f' to fetch." + ansi.reset);
1204
- }
1205
- return startRow + height;
1206
- }
1207
-
1208
- let row = startRow + 2;
1209
- for (let i = 0; i < displayBranches.length && i < visibleBranchCount; i++) {
1210
- const branch = displayBranches[i];
1211
- const isSelected = i === selectedIndex;
1212
- const isCurrent = branch.name === currentBranch;
1213
- const timeAgo = formatTimeAgo(branch.date);
1214
- const sparkline = sparklineCache.get(branch.name) || ' ';
1215
- const prStatus = branchPrStatusMap.get(branch.name); // { state, number, title } or undefined
1216
- // Never treat default/base branches as "merged" — they're merge targets, not sources
1217
- const isBranchBase = isBaseBranch(branch.name);
1218
- const isMerged = !isBranchBase && prStatus && prStatus.state === 'MERGED';
1219
- const hasOpenPr = prStatus && prStatus.state === 'OPEN';
1220
-
1221
- // Branch name line
1222
- write(ansi.moveTo(row, 2));
1223
-
1224
- // Cursor indicator
1225
- const cursor = isSelected ? ' ▶ ' : ' ';
1226
-
1227
- // Branch name - adjust for sparkline
1228
- const maxNameLen = contentWidth - 38; // Extra space for sparkline
1229
- const displayName = truncate(branch.name, maxNameLen);
1230
-
1231
- // Padding after name
1232
- const namePadding = Math.max(1, maxNameLen - displayName.length + 2);
1233
-
1234
- // Write the line
1235
- if (isSelected) write(ansi.inverse);
1236
- write(cursor);
1237
-
1238
- if (branch.isDeleted) {
1239
- write(ansi.gray + ansi.dim + displayName + ansi.reset);
1240
- if (isSelected) write(ansi.inverse);
1241
- } else if (isMerged && !isCurrent) {
1242
- // Merged branches get dimmed styling (like deleted, but in magenta tint)
1243
- write(ansi.dim + ansi.fg256(103) + displayName + ansi.reset);
1244
- if (isSelected) write(ansi.inverse);
1245
- } else if (isCurrent) {
1246
- write(ansi.green + ansi.bold + displayName + ansi.reset);
1247
- if (isSelected) write(ansi.inverse);
1248
- } else if (branch.justUpdated) {
1249
- write(ansi.yellow + displayName + ansi.reset);
1250
- if (isSelected) write(ansi.inverse);
1251
- branch.justUpdated = false;
1252
- } else {
1253
- write(displayName);
1254
- }
1255
-
1256
- write(' '.repeat(namePadding));
1257
-
1258
- // Sparkline (7 chars)
1259
- if (isSelected) write(ansi.reset);
1260
- if (isMerged && !isCurrent) {
1261
- write(ansi.dim + ansi.fg256(60) + sparkline + ansi.reset); // Dimmed sparkline for merged
1262
- } else {
1263
- write(ansi.fg256(39) + sparkline + ansi.reset); // Nice blue color
1264
- }
1265
- if (isSelected) write(ansi.inverse);
1266
-
1267
- // PR status dot indicator (1 char)
1268
- if (isSelected) write(ansi.reset);
1269
- if (isMerged) {
1270
- write(ansi.dim + ansi.magenta + '●' + ansi.reset);
1271
- } else if (hasOpenPr) {
1272
- write(ansi.brightGreen + '●' + ansi.reset);
1273
- } else {
1274
- write(' ');
1275
- }
1276
- if (isSelected) write(ansi.inverse);
1277
-
1278
- // Status badge
1279
- if (branch.isDeleted) {
1280
- if (isSelected) write(ansi.reset);
1281
- write(ansi.red + ansi.dim + '✗ DELETED' + ansi.reset);
1282
- if (isSelected) write(ansi.inverse);
1283
- } else if (isMerged && !isCurrent && !branch.isNew && !branch.hasUpdates) {
1284
- if (isSelected) write(ansi.reset);
1285
- write(ansi.dim + ansi.magenta + '✓ MERGED ' + ansi.reset);
1286
- if (isSelected) write(ansi.inverse);
1287
- } else if (isCurrent) {
1288
- if (isSelected) write(ansi.reset);
1289
- write(ansi.green + '★ CURRENT' + ansi.reset);
1290
- if (isSelected) write(ansi.inverse);
1291
- } else if (branch.isNew) {
1292
- if (isSelected) write(ansi.reset);
1293
- write(ansi.magenta + '✦ NEW ' + ansi.reset);
1294
- if (isSelected) write(ansi.inverse);
1295
- } else if (branch.hasUpdates) {
1296
- if (isSelected) write(ansi.reset);
1297
- write(ansi.yellow + '↓ UPDATES' + ansi.reset);
1298
- if (isSelected) write(ansi.inverse);
1299
- } else {
1300
- write(' ');
1301
- }
1302
-
1303
- // Time ago
1304
- write(' ');
1305
- if (isSelected) write(ansi.reset);
1306
- write(ansi.gray + padLeft(timeAgo, 10) + ansi.reset);
1307
-
1308
- if (isSelected) write(ansi.reset);
1309
-
1310
- row++;
1311
-
1312
- // Commit info line
1313
- write(ansi.moveTo(row, 2));
1314
- if (isMerged && !isCurrent) {
1315
- // Dimmed commit line for merged branches, with PR number
1316
- write(ansi.dim + ' └─ ' + ansi.reset);
1317
- write(ansi.dim + ansi.cyan + (branch.commit || '???????') + ansi.reset);
1318
- write(ansi.dim + ' • ' + ansi.reset);
1319
- const prTag = ansi.dim + ansi.magenta + '#' + prStatus.number + ansi.reset + ansi.dim + ' ';
1320
- write(prTag + ansi.gray + ansi.dim + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
1321
- } else {
1322
- write(' └─ ');
1323
- write(ansi.cyan + (branch.commit || '???????') + ansi.reset);
1324
- write(' • ');
1325
- if (hasOpenPr) {
1326
- // Show PR number inline for open PRs
1327
- const prTag = ansi.brightGreen + '#' + prStatus.number + ansi.reset + ' ';
1328
- write(prTag + ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
1329
- } else {
1330
- write(ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 22) + ansi.reset);
1331
- }
1332
- }
1333
-
1334
- row++;
1335
- }
1336
-
1337
- return startRow + height;
1014
+ write(renderClearArea(row, col, width, height));
1338
1015
  }
1339
1016
 
1340
- function renderActivityLog(startRow) {
1341
- const boxWidth = terminalWidth;
1342
- const contentWidth = boxWidth - 4;
1343
- const height = Math.min(MAX_LOG_ENTRIES + 3, terminalHeight - startRow - 4);
1344
-
1345
- drawBox(startRow, 1, boxWidth, height, 'ACTIVITY LOG', ansi.gray);
1346
-
1347
- // Clear content area first (fixes border gaps)
1348
- for (let i = 1; i < height - 1; i++) {
1349
- write(ansi.moveTo(startRow + i, 2));
1350
- write(' '.repeat(contentWidth + 2));
1351
- }
1352
-
1353
- let row = startRow + 1;
1354
- for (let i = 0; i < activityLog.length && i < height - 2; i++) {
1355
- const entry = activityLog[i];
1356
- write(ansi.moveTo(row, 3));
1357
- write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
1358
- write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
1359
- write(truncate(entry.message, contentWidth - 16));
1360
- row++;
1361
- }
1362
-
1363
- if (activityLog.length === 0) {
1364
- write(ansi.moveTo(startRow + 1, 3));
1365
- write(ansi.gray + 'No activity yet...' + ansi.reset);
1366
- }
1017
+ // renderHeader - now delegated to renderer.renderHeader()
1367
1018
 
1368
- return startRow + height;
1369
- }
1019
+ // renderBranchList, renderActivityLog — now delegated to renderer module (src/ui/renderer.js)
1370
1020
 
1371
1021
  function renderCasinoStats(startRow) {
1372
- if (!casinoModeEnabled) return startRow;
1022
+ if (!store.get('casinoModeEnabled')) return startRow;
1373
1023
 
1374
- const boxWidth = terminalWidth;
1024
+ const boxWidth = store.get('terminalWidth');
1375
1025
  const height = 6; // Box with two content lines
1376
1026
 
1377
1027
  // Don't draw if not enough space
1378
- if (startRow + height > terminalHeight - 3) return startRow;
1028
+ if (startRow + height > store.get('terminalHeight') - 3) return startRow;
1379
1029
 
1380
1030
  drawBox(startRow, 1, boxWidth, height, '🎰 CASINO WINNINGS 🎰', ansi.brightMagenta);
1381
1031
 
@@ -1411,667 +1061,17 @@ function renderCasinoStats(startRow) {
1411
1061
  return startRow + height;
1412
1062
  }
1413
1063
 
1414
- function renderFooter() {
1415
- const row = terminalHeight - 1;
1416
-
1417
- write(ansi.moveTo(row, 1));
1418
- write(ansi.bgBlack + ansi.white);
1419
- write(' ');
1420
- write(ansi.gray + '[↑↓]' + ansi.reset + ansi.bgBlack + ' Nav ');
1421
- write(ansi.gray + '[/]' + ansi.reset + ansi.bgBlack + ' Search ');
1422
- write(ansi.gray + '[v]' + ansi.reset + ansi.bgBlack + ' Preview ');
1423
- write(ansi.gray + '[Enter]' + ansi.reset + ansi.bgBlack + ' Switch ');
1424
- write(ansi.gray + '[h]' + ansi.reset + ansi.bgBlack + ' History ');
1425
- write(ansi.gray + '[i]' + ansi.reset + ansi.bgBlack + ' Info ');
1426
- write(ansi.gray + '[b]' + ansi.reset + ansi.bgBlack + ' Actions ');
1427
-
1428
- // Mode-specific keys
1429
- if (!NO_SERVER) {
1430
- write(ansi.gray + '[l]' + ansi.reset + ansi.bgBlack + ' Logs ');
1431
- write(ansi.gray + '[o]' + ansi.reset + ansi.bgBlack + ' Open ');
1432
- }
1433
- if (SERVER_MODE === 'static') {
1434
- write(ansi.gray + '[r]' + ansi.reset + ansi.bgBlack + ' Reload ');
1435
- } else if (SERVER_MODE === 'command') {
1436
- write(ansi.gray + '[R]' + ansi.reset + ansi.bgBlack + ' Restart ');
1437
- }
1438
-
1439
- write(ansi.gray + '[±]' + ansi.reset + ansi.bgBlack + ' List:' + ansi.cyan + visibleBranchCount + ansi.reset + ansi.bgBlack + ' ');
1440
-
1441
- // Casino mode toggle indicator
1442
- if (casinoModeEnabled) {
1443
- write(ansi.brightMagenta + '[c]' + ansi.reset + ansi.bgBlack + ' 🎰 ');
1444
- } else {
1445
- write(ansi.gray + '[c]' + ansi.reset + ansi.bgBlack + ' Casino ');
1446
- }
1447
-
1448
- write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
1449
- write(ansi.reset);
1450
- }
1451
-
1452
- function renderFlash() {
1453
- if (!flashMessage) return;
1454
-
1455
- const width = 50;
1456
- const height = 5;
1457
- const col = Math.floor((terminalWidth - width) / 2);
1458
- const row = Math.floor((terminalHeight - height) / 2);
1459
-
1460
- // Draw double-line box
1461
- write(ansi.moveTo(row, col));
1462
- write(ansi.yellow + ansi.bold);
1463
- write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1464
-
1465
- for (let i = 1; i < height - 1; i++) {
1466
- write(ansi.moveTo(row + i, col));
1467
- write(box.dVertical + ' '.repeat(width - 2) + box.dVertical);
1468
- }
1469
-
1470
- write(ansi.moveTo(row + height - 1, col));
1471
- write(box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1472
- write(ansi.reset);
1473
-
1474
- // Content
1475
- write(ansi.moveTo(row + 1, col + Math.floor((width - 16) / 2)));
1476
- write(ansi.yellow + ansi.bold + '⚡ NEW UPDATE ⚡' + ansi.reset);
1477
-
1478
- write(ansi.moveTo(row + 2, col + 2));
1479
- const truncMsg = truncate(flashMessage, width - 4);
1480
- write(ansi.white + truncMsg + ansi.reset);
1481
-
1482
- write(ansi.moveTo(row + 3, col + Math.floor((width - 22) / 2)));
1483
- write(ansi.gray + 'Press any key to dismiss' + ansi.reset);
1484
- }
1485
-
1486
- function renderErrorToast() {
1487
- if (!errorToast) return;
1488
-
1489
- const width = Math.min(60, terminalWidth - 4);
1490
- const col = Math.floor((terminalWidth - width) / 2);
1491
- const row = 2; // Near the top, below header
1492
-
1493
- // Calculate height based on content
1494
- const lines = [];
1495
- lines.push(errorToast.title || 'Git Error');
1496
- lines.push('');
1497
-
1498
- // Word wrap the message
1499
- const msgWords = errorToast.message.split(' ');
1500
- let currentLine = '';
1501
- for (const word of msgWords) {
1502
- if ((currentLine + ' ' + word).length > width - 6) {
1503
- lines.push(currentLine.trim());
1504
- currentLine = word;
1505
- } else {
1506
- currentLine += (currentLine ? ' ' : '') + word;
1507
- }
1508
- }
1509
- if (currentLine) lines.push(currentLine.trim());
1510
-
1511
- if (errorToast.hint) {
1512
- lines.push('');
1513
- lines.push(errorToast.hint);
1514
- }
1515
- lines.push('');
1516
- lines.push('Press any key to dismiss');
1517
-
1518
- const height = lines.length + 2;
1519
-
1520
- // Draw red error box
1521
- write(ansi.moveTo(row, col));
1522
- write(ansi.red + ansi.bold);
1523
- write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1524
-
1525
- for (let i = 1; i < height - 1; i++) {
1526
- write(ansi.moveTo(row + i, col));
1527
- write(ansi.red + box.dVertical + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 2) + ansi.reset + ansi.red + box.dVertical + ansi.reset);
1528
- }
1529
-
1530
- write(ansi.moveTo(row + height - 1, col));
1531
- write(ansi.red + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1532
- write(ansi.reset);
1533
-
1534
- // Render content
1535
- let contentRow = row + 1;
1536
- for (let i = 0; i < lines.length; i++) {
1537
- const line = lines[i];
1538
- write(ansi.moveTo(contentRow, col + 2));
1539
- write(ansi.bgRed + ansi.white);
1540
-
1541
- if (i === 0) {
1542
- // Title line - centered and bold
1543
- const titlePadding = Math.floor((width - 4 - line.length) / 2);
1544
- write(' '.repeat(titlePadding) + ansi.bold + line + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 4 - titlePadding - line.length));
1545
- } else if (line === 'Press any key to dismiss') {
1546
- // Instruction line - centered and dimmer
1547
- const padding = Math.floor((width - 4 - line.length) / 2);
1548
- write(ansi.reset + ansi.bgRed + ansi.gray + ' '.repeat(padding) + line + ' '.repeat(width - 4 - padding - line.length));
1549
- } else if (errorToast.hint && line === errorToast.hint) {
1550
- // Hint line - yellow on red
1551
- const padding = Math.floor((width - 4 - line.length) / 2);
1552
- write(ansi.reset + ansi.bgRed + ansi.yellow + ' '.repeat(padding) + line + ' '.repeat(width - 4 - padding - line.length));
1553
- } else {
1554
- // Regular content
1555
- write(padRight(line, width - 4));
1556
- }
1557
- write(ansi.reset);
1558
- contentRow++;
1559
- }
1560
- }
1561
-
1562
- function renderPreview() {
1563
- if (!previewMode || !previewData) return;
1564
-
1565
- const width = Math.min(60, terminalWidth - 4);
1566
- const height = 16;
1567
- const col = Math.floor((terminalWidth - width) / 2);
1568
- const row = Math.floor((terminalHeight - height) / 2);
1569
-
1570
- const displayBranches = filteredBranches !== null ? filteredBranches : branches;
1571
- const branch = displayBranches[selectedIndex];
1572
- if (!branch) return;
1573
-
1574
- // Draw box
1575
- write(ansi.moveTo(row, col));
1576
- write(ansi.cyan + ansi.bold);
1577
- write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1578
-
1579
- for (let i = 1; i < height - 1; i++) {
1580
- write(ansi.moveTo(row + i, col));
1581
- write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
1582
- }
1583
-
1584
- write(ansi.moveTo(row + height - 1, col));
1585
- write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1586
- write(ansi.reset);
1587
-
1588
- // Title
1589
- const title = ` Preview: ${truncate(branch.name, width - 14)} `;
1590
- write(ansi.moveTo(row, col + 2));
1591
- write(ansi.cyan + ansi.bold + title + ansi.reset);
1592
-
1593
- // Commits section
1594
- write(ansi.moveTo(row + 2, col + 2));
1595
- write(ansi.white + ansi.bold + 'Recent Commits:' + ansi.reset);
1596
-
1597
- let contentRow = row + 3;
1598
- if (previewData.commits.length === 0) {
1599
- write(ansi.moveTo(contentRow, col + 3));
1600
- write(ansi.gray + '(no commits)' + ansi.reset);
1601
- contentRow++;
1602
- } else {
1603
- for (const commit of previewData.commits.slice(0, 5)) {
1604
- write(ansi.moveTo(contentRow, col + 3));
1605
- write(ansi.yellow + commit.hash + ansi.reset + ' ');
1606
- write(ansi.gray + truncate(commit.message, width - 14) + ansi.reset);
1607
- contentRow++;
1608
- }
1609
- }
1610
-
1611
- // Files section
1612
- contentRow++;
1613
- write(ansi.moveTo(contentRow, col + 2));
1614
- write(ansi.white + ansi.bold + 'Files Changed vs HEAD:' + ansi.reset);
1615
- contentRow++;
1616
-
1617
- if (previewData.filesChanged.length === 0) {
1618
- write(ansi.moveTo(contentRow, col + 3));
1619
- write(ansi.gray + '(no changes or same as current)' + ansi.reset);
1620
- } else {
1621
- for (const file of previewData.filesChanged.slice(0, 5)) {
1622
- write(ansi.moveTo(contentRow, col + 3));
1623
- write(ansi.green + '• ' + ansi.reset + truncate(file, width - 8));
1624
- contentRow++;
1625
- }
1626
- if (previewData.filesChanged.length > 5) {
1627
- write(ansi.moveTo(contentRow, col + 3));
1628
- write(ansi.gray + `... and ${previewData.filesChanged.length - 5} more` + ansi.reset);
1629
- }
1630
- }
1631
-
1632
- // Instructions
1633
- write(ansi.moveTo(row + height - 2, col + Math.floor((width - 26) / 2)));
1634
- write(ansi.gray + 'Press [v] or [Esc] to close' + ansi.reset);
1635
- }
1636
-
1637
- function renderHistory() {
1638
- const width = Math.min(50, terminalWidth - 4);
1639
- const height = Math.min(switchHistory.length + 5, 15);
1640
- const col = Math.floor((terminalWidth - width) / 2);
1641
- const row = Math.floor((terminalHeight - height) / 2);
1642
-
1643
- // Draw box
1644
- write(ansi.moveTo(row, col));
1645
- write(ansi.magenta + ansi.bold);
1646
- write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1647
-
1648
- for (let i = 1; i < height - 1; i++) {
1649
- write(ansi.moveTo(row + i, col));
1650
- write(ansi.magenta + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.magenta + box.dVertical + ansi.reset);
1651
- }
1652
-
1653
- write(ansi.moveTo(row + height - 1, col));
1654
- write(ansi.magenta + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1655
- write(ansi.reset);
1064
+ // renderFooter, renderFlash, renderErrorToast, renderPreview, renderHistory
1065
+ // now delegated to renderer module (src/ui/renderer.js)
1656
1066
 
1657
- // Title
1658
- write(ansi.moveTo(row, col + 2));
1659
- write(ansi.magenta + ansi.bold + ' Switch History ' + ansi.reset);
1067
+ // renderLogView, renderInfo, renderActionModal
1068
+ // now delegated to renderer module (src/ui/renderer.js)
1660
1069
 
1661
- // Content
1662
- if (switchHistory.length === 0) {
1663
- write(ansi.moveTo(row + 2, col + 3));
1664
- write(ansi.gray + 'No branch switches yet' + ansi.reset);
1665
- } else {
1666
- let contentRow = row + 2;
1667
- for (let i = 0; i < Math.min(switchHistory.length, height - 4); i++) {
1668
- const entry = switchHistory[i];
1669
- write(ansi.moveTo(contentRow, col + 3));
1670
- if (i === 0) {
1671
- write(ansi.yellow + '→ ' + ansi.reset); // Most recent
1672
- } else {
1673
- write(ansi.gray + ' ' + ansi.reset);
1674
- }
1675
- write(truncate(entry.from, 15) + ansi.gray + ' → ' + ansi.reset);
1676
- write(ansi.cyan + truncate(entry.to, 15) + ansi.reset);
1677
- contentRow++;
1678
- }
1679
- }
1680
-
1681
- // Instructions
1682
- write(ansi.moveTo(row + height - 2, col + 2));
1683
- write(ansi.gray + '[u] Undo last [h]/[Esc] Close' + ansi.reset);
1684
- }
1685
-
1686
- let historyMode = false;
1687
- let infoMode = false;
1688
-
1689
- function renderLogView() {
1690
- if (!logViewMode) return;
1691
-
1692
- const width = Math.min(terminalWidth - 4, 100);
1693
- const height = Math.min(terminalHeight - 4, 30);
1694
- const col = Math.floor((terminalWidth - width) / 2);
1695
- const row = Math.floor((terminalHeight - height) / 2);
1696
-
1697
- // Determine which log to display
1698
- const isServerTab = logViewTab === 'server';
1699
- const logData = isServerTab ? serverLogBuffer : activityLog;
1700
-
1701
- // Draw box
1702
- write(ansi.moveTo(row, col));
1703
- write(ansi.yellow + ansi.bold);
1704
- write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1705
-
1706
- for (let i = 1; i < height - 1; i++) {
1707
- write(ansi.moveTo(row + i, col));
1708
- write(ansi.yellow + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.yellow + box.dVertical + ansi.reset);
1709
- }
1710
-
1711
- write(ansi.moveTo(row + height - 1, col));
1712
- write(ansi.yellow + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1713
- write(ansi.reset);
1714
-
1715
- // Title with tabs
1716
- const activityTab = logViewTab === 'activity'
1717
- ? ansi.bgWhite + ansi.black + ' 1:Activity ' + ansi.reset + ansi.yellow
1718
- : ansi.gray + ' 1:Activity ' + ansi.yellow;
1719
- const serverTab = logViewTab === 'server'
1720
- ? ansi.bgWhite + ansi.black + ' 2:Server ' + ansi.reset + ansi.yellow
1721
- : ansi.gray + ' 2:Server ' + ansi.yellow;
1722
-
1723
- // Server status (only show on server tab)
1724
- let statusIndicator = '';
1725
- if (isServerTab && SERVER_MODE === 'command') {
1726
- const statusText = serverRunning ? ansi.green + 'RUNNING' : (serverCrashed ? ansi.red + 'CRASHED' : ansi.gray + 'STOPPED');
1727
- statusIndicator = ` [${statusText}${ansi.yellow}]`;
1728
- } else if (isServerTab && SERVER_MODE === 'static') {
1729
- statusIndicator = ansi.green + ' [STATIC]' + ansi.yellow;
1730
- }
1731
-
1732
- write(ansi.moveTo(row, col + 2));
1733
- write(ansi.yellow + ansi.bold + ' ' + activityTab + ' ' + serverTab + statusIndicator + ' ' + ansi.reset);
1734
-
1735
- // Content
1736
- const contentHeight = height - 4;
1737
- const maxScroll = Math.max(0, logData.length - contentHeight);
1738
- logScrollOffset = Math.min(logScrollOffset, maxScroll);
1739
- logScrollOffset = Math.max(0, logScrollOffset);
1740
-
1741
- let contentRow = row + 2;
1742
-
1743
- if (logData.length === 0) {
1744
- write(ansi.moveTo(contentRow, col + 2));
1745
- write(ansi.gray + (isServerTab ? 'No server output yet...' : 'No activity yet...') + ansi.reset);
1746
- } else if (isServerTab) {
1747
- // Server log: newest at bottom, scroll from bottom
1748
- const startIndex = Math.max(0, serverLogBuffer.length - contentHeight - logScrollOffset);
1749
- const endIndex = Math.min(serverLogBuffer.length, startIndex + contentHeight);
1750
-
1751
- for (let i = startIndex; i < endIndex; i++) {
1752
- const entry = serverLogBuffer[i];
1753
- write(ansi.moveTo(contentRow, col + 2));
1754
- const lineText = truncate(entry.line, width - 4);
1755
- if (entry.isError) {
1756
- write(ansi.red + lineText + ansi.reset);
1757
- } else {
1758
- write(lineText);
1759
- }
1760
- contentRow++;
1761
- }
1762
- } else {
1763
- // Activity log: newest first, scroll from top
1764
- const startIndex = logScrollOffset;
1765
- const endIndex = Math.min(activityLog.length, startIndex + contentHeight);
1766
-
1767
- for (let i = startIndex; i < endIndex; i++) {
1768
- const entry = activityLog[i];
1769
- write(ansi.moveTo(contentRow, col + 2));
1770
- write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
1771
- write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
1772
- write(truncate(entry.message, width - 18));
1773
- contentRow++;
1774
- }
1775
- }
1776
-
1777
- // Scroll indicator
1778
- if (logData.length > contentHeight) {
1779
- const scrollPercent = isServerTab
1780
- ? Math.round((1 - logScrollOffset / maxScroll) * 100)
1781
- : Math.round((logScrollOffset / maxScroll) * 100);
1782
- write(ansi.moveTo(row, col + width - 10));
1783
- write(ansi.gray + ` ${scrollPercent}% ` + ansi.reset);
1784
- }
1785
-
1786
- // Instructions
1787
- write(ansi.moveTo(row + height - 2, col + 2));
1788
- const restartHint = SERVER_MODE === 'command' ? '[R] Restart ' : '';
1789
- write(ansi.gray + '[1/2] Switch Tab [↑↓] Scroll ' + restartHint + '[l]/[Esc] Close' + ansi.reset);
1790
- }
1791
-
1792
- function renderInfo() {
1793
- const width = Math.min(50, terminalWidth - 4);
1794
- const height = NO_SERVER ? 9 : 12;
1795
- const col = Math.floor((terminalWidth - width) / 2);
1796
- const row = Math.floor((terminalHeight - height) / 2);
1797
-
1798
- // Draw box
1799
- write(ansi.moveTo(row, col));
1800
- write(ansi.cyan + ansi.bold);
1801
- write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1802
-
1803
- for (let i = 1; i < height - 1; i++) {
1804
- write(ansi.moveTo(row + i, col));
1805
- write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
1806
- }
1807
-
1808
- write(ansi.moveTo(row + height - 1, col));
1809
- write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1810
- write(ansi.reset);
1811
-
1812
- // Title
1813
- write(ansi.moveTo(row, col + 2));
1814
- write(ansi.cyan + ansi.bold + (NO_SERVER ? ' Status Info ' : ' Server Info ') + ansi.reset);
1815
-
1816
- // Content
1817
- let contentRow = row + 2;
1818
-
1819
- if (!NO_SERVER) {
1820
- write(ansi.moveTo(contentRow, col + 3));
1821
- write(ansi.white + ansi.bold + 'Dev Server' + ansi.reset);
1822
- contentRow++;
1823
-
1824
- write(ansi.moveTo(contentRow, col + 3));
1825
- write(ansi.gray + 'URL: ' + ansi.reset + ansi.green + `http://localhost:${PORT}` + ansi.reset);
1826
- contentRow++;
1827
-
1828
- write(ansi.moveTo(contentRow, col + 3));
1829
- write(ansi.gray + 'Port: ' + ansi.reset + ansi.yellow + PORT + ansi.reset);
1830
- contentRow++;
1831
-
1832
- write(ansi.moveTo(contentRow, col + 3));
1833
- write(ansi.gray + 'Connected browsers: ' + ansi.reset + ansi.cyan + clients.size + ansi.reset);
1834
- contentRow++;
1835
-
1836
- contentRow++;
1837
- }
1838
-
1839
- write(ansi.moveTo(contentRow, col + 3));
1840
- write(ansi.white + ansi.bold + 'Git Polling' + ansi.reset);
1841
- contentRow++;
1842
-
1843
- write(ansi.moveTo(contentRow, col + 3));
1844
- write(ansi.gray + 'Interval: ' + ansi.reset + `${adaptivePollInterval / 1000}s`);
1845
- contentRow++;
1846
-
1847
- write(ansi.moveTo(contentRow, col + 3));
1848
- write(ansi.gray + 'Status: ' + ansi.reset + (isOffline ? ansi.red + 'Offline' : ansi.green + 'Online') + ansi.reset);
1849
- contentRow++;
1850
-
1851
- if (NO_SERVER) {
1852
- write(ansi.moveTo(contentRow, col + 3));
1853
- write(ansi.gray + 'Mode: ' + ansi.reset + ansi.magenta + 'No-Server (branch monitor only)' + ansi.reset);
1854
- }
1855
-
1856
- // Instructions
1857
- write(ansi.moveTo(row + height - 2, col + Math.floor((width - 20) / 2)));
1858
- write(ansi.gray + 'Press [i] or [Esc] to close' + ansi.reset);
1859
- }
1860
-
1861
- function renderActionModal() {
1862
- if (!actionMode || !actionData) return;
1863
-
1864
- const { branch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, isClaudeBranch, platform, prLoaded } = actionData;
1865
-
1866
- const width = Math.min(64, terminalWidth - 4);
1867
- const innerW = width - 6;
1868
-
1869
- const platformLabel = platform === 'gitlab' ? 'GitLab' : platform === 'bitbucket' ? 'Bitbucket' : platform === 'azure' ? 'Azure DevOps' : 'GitHub';
1870
- const prLabel = platform === 'gitlab' ? 'MR' : 'PR';
1871
- const cliTool = platform === 'gitlab' ? 'glab' : 'gh';
1872
- const hasCli = platform === 'gitlab' ? hasGlab : hasGh;
1873
- const cliAuthed = platform === 'gitlab' ? glabAuthed : ghAuthed;
1874
- const cliReady = hasCli && cliAuthed;
1875
- const loading = actionLoading; // PR info still loading
1876
-
1877
- // Build actions list — ALL actions always shown, grayed out with reasons when unavailable
1878
- // { key, label, available, reason, loading }
1879
- const actions = [];
1880
-
1881
- // Open on web
1882
- actions.push({
1883
- key: 'b', label: `Open branch on ${platformLabel}`,
1884
- available: !!webUrl, reason: !webUrl ? 'Could not parse remote URL' : null,
1885
- });
1886
-
1887
- // Claude session — always shown so users know it exists
1888
- actions.push({
1889
- key: 'c', label: 'Open Claude Code session',
1890
- available: !!sessionUrl,
1891
- reason: !isClaudeBranch ? 'Not a Claude branch' : !sessionUrl && !loading ? 'No session URL in commits' : null,
1892
- loading: isClaudeBranch && !sessionUrl && loading,
1893
- });
1894
-
1895
- // PR: create or view depending on state
1896
- const prIsMerged = prInfo && (prInfo.state === 'MERGED' || prInfo.state === 'merged');
1897
- const prIsOpen = prInfo && (prInfo.state === 'OPEN' || prInfo.state === 'open');
1898
- if (prInfo) {
1899
- actions.push({ key: 'p', label: `View ${prLabel} #${prInfo.number}`, available: !!webUrl, reason: null });
1900
- } else {
1901
- actions.push({
1902
- key: 'p', label: `Create ${prLabel}`,
1903
- available: cliReady && prLoaded,
1904
- reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : null,
1905
- loading: cliReady && !prLoaded,
1906
- });
1907
- }
1908
-
1909
- // Diff — opens on web, just needs a PR and webUrl
1910
- actions.push({
1911
- key: 'd', label: `View ${prLabel} diff on ${platformLabel}`,
1912
- available: !!prInfo && !!webUrl,
1913
- reason: !prInfo && prLoaded ? `No ${prLabel}` : !webUrl ? 'Could not parse remote URL' : null,
1914
- loading: !prLoaded && (cliReady || !!webUrl),
1915
- });
1916
-
1917
- // Approve — disabled for merged PRs
1918
- actions.push({
1919
- key: 'a', label: `Approve ${prLabel}`,
1920
- available: !!prInfo && prIsOpen && cliReady,
1921
- reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
1922
- loading: cliReady && !prLoaded,
1923
- });
1924
-
1925
- // Merge — disabled for already-merged PRs
1926
- actions.push({
1927
- key: 'm', label: `Merge ${prLabel} (squash)`,
1928
- available: !!prInfo && prIsOpen && cliReady,
1929
- reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
1930
- loading: cliReady && !prLoaded,
1931
- });
1932
-
1933
- // CI
1934
- actions.push({
1935
- key: 'i', label: 'Check CI status',
1936
- available: cliReady && (!!prInfo || platform === 'gitlab'),
1937
- reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded && platform !== 'gitlab' ? `No open ${prLabel}` : null,
1938
- loading: cliReady && !prLoaded && platform !== 'gitlab',
1939
- });
1940
-
1941
- // Calculate height
1942
- let contentLines = 0;
1943
- contentLines += 2; // spacing + branch name
1944
- contentLines += 1; // separator
1945
- contentLines += actions.length;
1946
- contentLines += 1; // separator
1947
-
1948
- // Status info
1949
- const statusInfoLines = [];
1950
- if (prInfo) {
1951
- let prStatus = `${prLabel} #${prInfo.number}: ${truncate(prInfo.title, innerW - 20)}`;
1952
- const badges = [];
1953
- if (prIsMerged) badges.push('merged');
1954
- if (prInfo.approved) badges.push('approved');
1955
- if (prInfo.checksPass) badges.push('checks pass');
1956
- if (prInfo.checksFail) badges.push('checks fail');
1957
- if (badges.length) prStatus += ` [${badges.join(', ')}]`;
1958
- statusInfoLines.push({ color: prIsMerged ? 'magenta' : 'green', text: prStatus });
1959
- } else if (loading) {
1960
- statusInfoLines.push({ color: 'gray', text: `Loading ${prLabel} info...` });
1961
- } else if (cliReady) {
1962
- statusInfoLines.push({ color: 'gray', text: `No ${prLabel} for this branch` });
1963
- }
1964
-
1965
- if (isClaudeBranch) {
1966
- if (sessionUrl) {
1967
- const shortSession = sessionUrl.replace('https://claude.ai/code/', '');
1968
- statusInfoLines.push({ color: 'magenta', text: `Session: ${truncate(shortSession, innerW - 10)}` });
1969
- } else if (!loading) {
1970
- statusInfoLines.push({ color: 'gray', text: 'Claude branch (no session URL in commits)' });
1971
- }
1972
- }
1973
-
1974
- contentLines += statusInfoLines.length;
1975
-
1976
- // Setup hints
1977
- const hints = [];
1978
- if (!hasCli) {
1979
- if (platform === 'gitlab') {
1980
- hints.push(`Install glab: https://gitlab.com/gitlab-org/cli`);
1981
- hints.push(`Then run: glab auth login`);
1982
- } else {
1983
- hints.push(`Install gh: https://cli.github.com`);
1984
- hints.push(`Then run: gh auth login`);
1985
- }
1986
- } else if (!cliAuthed) {
1987
- hints.push(`${cliTool} is installed but not authenticated`);
1988
- hints.push(`Run: ${cliTool} auth login`);
1989
- }
1990
-
1991
- if (hints.length > 0) {
1992
- contentLines += 1;
1993
- contentLines += hints.length;
1994
- }
1995
-
1996
- contentLines += 2; // blank + close instructions
1997
-
1998
- const height = contentLines + 3;
1999
- const col = Math.floor((terminalWidth - width) / 2);
2000
- const row = Math.floor((terminalHeight - height) / 2);
2001
-
2002
- // Draw box
2003
- const borderColor = ansi.brightCyan;
2004
- write(ansi.moveTo(row, col));
2005
- write(borderColor + ansi.bold);
2006
- write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
2007
-
2008
- for (let i = 1; i < height - 1; i++) {
2009
- write(ansi.moveTo(row + i, col));
2010
- write(borderColor + box.dVertical + ansi.reset + ' '.repeat(width - 2) + borderColor + box.dVertical + ansi.reset);
2011
- }
2012
-
2013
- write(ansi.moveTo(row + height - 1, col));
2014
- write(borderColor + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
2015
- write(ansi.reset);
2016
-
2017
- // Title
2018
- const title = ' Branch Actions ';
2019
- write(ansi.moveTo(row, col + 2));
2020
- write(borderColor + ansi.bold + title + ansi.reset);
2021
-
2022
- let r = row + 2;
2023
-
2024
- // Branch name with type indicator
2025
- write(ansi.moveTo(r, col + 3));
2026
- write(ansi.white + ansi.bold + truncate(branch.name, innerW - 10) + ansi.reset);
2027
- if (isClaudeBranch) {
2028
- write(ansi.magenta + ' [Claude]' + ansi.reset);
2029
- }
2030
- r++;
2031
-
2032
- // Separator
2033
- r++;
2034
-
2035
- // Actions list — all always visible
2036
- for (const action of actions) {
2037
- write(ansi.moveTo(r, col + 3));
2038
- if (action.loading) {
2039
- write(ansi.gray + '[' + action.key + '] ' + action.label + ' ' + ansi.dim + ansi.cyan + 'loading...' + ansi.reset);
2040
- } else if (action.available) {
2041
- write(ansi.brightCyan + '[' + action.key + ']' + ansi.reset + ' ' + action.label);
2042
- } else {
2043
- write(ansi.gray + '[' + action.key + '] ' + action.label);
2044
- if (action.reason) {
2045
- write(' ' + ansi.dim + ansi.yellow + action.reason + ansi.reset);
2046
- }
2047
- write(ansi.reset);
2048
- }
2049
- r++;
2050
- }
2051
-
2052
- // Separator
2053
- r++;
2054
-
2055
- // Status info
2056
- for (const info of statusInfoLines) {
2057
- write(ansi.moveTo(r, col + 3));
2058
- write(ansi[info.color] + truncate(info.text, innerW) + ansi.reset);
2059
- r++;
2060
- }
2061
-
2062
- // Setup hints
2063
- if (hints.length > 0) {
2064
- r++;
2065
- for (const hint of hints) {
2066
- write(ansi.moveTo(r, col + 3));
2067
- write(ansi.yellow + truncate(hint, innerW) + ansi.reset);
2068
- r++;
2069
- }
2070
- }
2071
-
2072
- // Close instructions
2073
- write(ansi.moveTo(row + height - 2, col + Math.floor((width - 18) / 2)));
2074
- write(ansi.gray + 'Press [Esc] to close' + ansi.reset);
1070
+ // Build a state snapshot from the current globals for the renderer
1071
+ function getRenderState() {
1072
+ const s = store.getState();
1073
+ s.clientCount = clients.size;
1074
+ return s;
2075
1075
  }
2076
1076
 
2077
1077
  function render() {
@@ -2081,17 +1081,21 @@ function render() {
2081
1081
  write(ansi.moveToTop);
2082
1082
  write(ansi.clearScreen);
2083
1083
 
1084
+ const state = getRenderState();
1085
+ const { casinoModeEnabled, terminalWidth, terminalHeight } = state;
1086
+
2084
1087
  // Casino mode: top marquee border
2085
1088
  if (casinoModeEnabled) {
2086
1089
  write(ansi.moveTo(1, 1));
2087
1090
  write(casino.renderMarqueeLine(terminalWidth, 'top'));
2088
1091
  }
2089
1092
 
2090
- renderHeader();
2091
- const logStart = renderBranchList();
2092
- const statsStart = renderActivityLog(logStart);
1093
+ // Delegate to extracted renderer module
1094
+ renderer.renderHeader(state, write);
1095
+ const logStart = renderer.renderBranchList(state, write);
1096
+ const statsStart = renderer.renderActivityLog(state, write, logStart);
2093
1097
  renderCasinoStats(statsStart);
2094
- renderFooter();
1098
+ renderer.renderFooter(state, write);
2095
1099
 
2096
1100
  // Casino mode: full border (top, bottom, left, right)
2097
1101
  if (casinoModeEnabled) {
@@ -2114,7 +1118,6 @@ function render() {
2114
1118
  if (casinoModeEnabled && casino.isSlotsActive()) {
2115
1119
  const slotDisplay = casino.getSlotReelDisplay();
2116
1120
  if (slotDisplay) {
2117
- // Row 3: below header (row 1 is marquee, row 2 is header)
2118
1121
  const resultLabel = casino.getSlotResultLabel();
2119
1122
  let leftLabel, rightLabel;
2120
1123
 
@@ -2123,7 +1126,6 @@ function render() {
2123
1126
  rightLabel = '';
2124
1127
  } else if (resultLabel) {
2125
1128
  leftLabel = ansi.bgBrightGreen + ansi.black + ansi.bold + ' RESULT ' + ansi.reset;
2126
- // Flash effect for jackpots, use result color for text
2127
1129
  const flash = resultLabel.isJackpot && (Math.floor(Date.now() / 150) % 2 === 0);
2128
1130
  const bgColor = flash ? ansi.bgBrightYellow : ansi.bgWhite;
2129
1131
  rightLabel = ' ' + bgColor + resultLabel.color + ansi.bold + ' ' + resultLabel.text + ' ' + ansi.reset;
@@ -2133,7 +1135,7 @@ function render() {
2133
1135
  }
2134
1136
 
2135
1137
  const fullDisplay = leftLabel + ' ' + slotDisplay + rightLabel;
2136
- const col = Math.floor((terminalWidth - 70) / 2); // Center the display
1138
+ const col = Math.floor((terminalWidth - 70) / 2);
2137
1139
  write(ansi.moveTo(3, Math.max(2, col)));
2138
1140
  write(fullDisplay);
2139
1141
  }
@@ -2159,44 +1161,50 @@ function render() {
2159
1161
  }
2160
1162
  }
2161
1163
 
2162
- if (flashMessage) {
2163
- renderFlash();
1164
+ // Delegate modal/overlay rendering to extracted renderer
1165
+ if (state.flashMessage) {
1166
+ renderer.renderFlash(state, write);
2164
1167
  }
2165
1168
 
2166
- if (previewMode && previewData) {
2167
- renderPreview();
1169
+ if (state.previewMode && state.previewData) {
1170
+ renderer.renderPreview(state, write);
2168
1171
  }
2169
1172
 
2170
- if (historyMode) {
2171
- renderHistory();
1173
+ if (state.historyMode) {
1174
+ renderer.renderHistory(state, write);
2172
1175
  }
2173
1176
 
2174
- if (infoMode) {
2175
- renderInfo();
1177
+ if (state.infoMode) {
1178
+ renderer.renderInfo(state, write);
2176
1179
  }
2177
1180
 
2178
- if (logViewMode) {
2179
- renderLogView();
1181
+ if (state.logViewMode) {
1182
+ renderer.renderLogView(state, write);
2180
1183
  }
2181
1184
 
2182
- if (actionMode) {
2183
- renderActionModal();
1185
+ if (state.actionMode) {
1186
+ renderer.renderActionModal(state, write);
2184
1187
  }
2185
1188
 
2186
1189
  // Error toast renders on top of everything for maximum visibility
2187
- if (errorToast) {
2188
- renderErrorToast();
1190
+ if (state.errorToast) {
1191
+ renderer.renderErrorToast(state, write);
1192
+ }
1193
+
1194
+ // Stash confirmation dialog renders on top of everything
1195
+ if (state.stashConfirmMode) {
1196
+ renderer.renderStashConfirm(state, write);
2189
1197
  }
2190
1198
  }
2191
1199
 
2192
1200
  function showFlash(message) {
2193
1201
  if (flashTimeout) clearTimeout(flashTimeout);
2194
1202
 
2195
- flashMessage = message;
1203
+ store.setState({ flashMessage: message });
2196
1204
  render();
2197
1205
 
2198
1206
  flashTimeout = setTimeout(() => {
2199
- flashMessage = null;
1207
+ store.setState({ flashMessage: null });
2200
1208
  render();
2201
1209
  }, 3000);
2202
1210
  }
@@ -2206,8 +1214,8 @@ function hideFlash() {
2206
1214
  clearTimeout(flashTimeout);
2207
1215
  flashTimeout = null;
2208
1216
  }
2209
- if (flashMessage) {
2210
- flashMessage = null;
1217
+ if (store.get('flashMessage')) {
1218
+ store.setState({ flashMessage: null });
2211
1219
  render();
2212
1220
  }
2213
1221
  }
@@ -2215,12 +1223,12 @@ function hideFlash() {
2215
1223
  function showErrorToast(title, message, hint = null, duration = 8000) {
2216
1224
  if (errorToastTimeout) clearTimeout(errorToastTimeout);
2217
1225
 
2218
- errorToast = { title, message, hint };
1226
+ store.setState({ errorToast: { title, message, hint } });
2219
1227
  playSound(); // Alert sound for errors
2220
1228
  render();
2221
1229
 
2222
1230
  errorToastTimeout = setTimeout(() => {
2223
- errorToast = null;
1231
+ store.setState({ errorToast: null });
2224
1232
  render();
2225
1233
  }, duration);
2226
1234
  }
@@ -2230,8 +1238,28 @@ function hideErrorToast() {
2230
1238
  clearTimeout(errorToastTimeout);
2231
1239
  errorToastTimeout = null;
2232
1240
  }
2233
- if (errorToast) {
2234
- errorToast = null;
1241
+ if (store.get('errorToast')) {
1242
+ store.setState({ errorToast: null });
1243
+ render();
1244
+ }
1245
+ }
1246
+
1247
+ function showStashConfirm(operationLabel) {
1248
+ store.setState({
1249
+ stashConfirmMode: true,
1250
+ stashConfirmSelectedIndex: 0,
1251
+ pendingDirtyOperationLabel: operationLabel,
1252
+ });
1253
+ render();
1254
+ }
1255
+
1256
+ function hideStashConfirm() {
1257
+ if (store.get('stashConfirmMode')) {
1258
+ store.setState({
1259
+ stashConfirmMode: false,
1260
+ stashConfirmSelectedIndex: 0,
1261
+ pendingDirtyOperationLabel: null,
1262
+ });
2235
1263
  render();
2236
1264
  }
2237
1265
  }
@@ -2245,12 +1273,12 @@ async function getCurrentBranch() {
2245
1273
  const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD');
2246
1274
  // Check for detached HEAD state
2247
1275
  if (stdout === 'HEAD') {
2248
- isDetachedHead = true;
1276
+ store.setState({ isDetachedHead: true });
2249
1277
  // Get the short commit hash instead
2250
1278
  const { stdout: commitHash } = await execAsync('git rev-parse --short HEAD');
2251
1279
  return `HEAD@${commitHash}`;
2252
1280
  }
2253
- isDetachedHead = false;
1281
+ store.setState({ isDetachedHead: false });
2254
1282
  return stdout;
2255
1283
  } catch (e) {
2256
1284
  return null;
@@ -2276,46 +1304,7 @@ async function hasUncommittedChanges() {
2276
1304
  }
2277
1305
  }
2278
1306
 
2279
- function isAuthError(errorMessage) {
2280
- const authErrors = [
2281
- 'Authentication failed',
2282
- 'could not read Username',
2283
- 'could not read Password',
2284
- 'Permission denied',
2285
- 'invalid credentials',
2286
- 'authorization failed',
2287
- 'fatal: Authentication',
2288
- 'HTTP 401',
2289
- 'HTTP 403',
2290
- ];
2291
- const msg = (errorMessage || '').toLowerCase();
2292
- return authErrors.some(err => msg.includes(err.toLowerCase()));
2293
- }
2294
-
2295
- function isMergeConflict(errorMessage) {
2296
- const conflictIndicators = [
2297
- 'CONFLICT',
2298
- 'Automatic merge failed',
2299
- 'fix conflicts',
2300
- 'Merge conflict',
2301
- ];
2302
- return conflictIndicators.some(ind => (errorMessage || '').includes(ind));
2303
- }
2304
-
2305
- function isNetworkError(errorMessage) {
2306
- const networkErrors = [
2307
- 'Could not resolve host',
2308
- 'unable to access',
2309
- 'Connection refused',
2310
- 'Network is unreachable',
2311
- 'Connection timed out',
2312
- 'Failed to connect',
2313
- 'no route to host',
2314
- 'Temporary failure in name resolution',
2315
- ];
2316
- const msg = (errorMessage || '').toLowerCase();
2317
- return networkErrors.some(err => msg.includes(err.toLowerCase()));
2318
- }
1307
+ // isAuthError, isMergeConflict, isNetworkError imported from src/utils/errors.js
2319
1308
 
2320
1309
  async function getAllBranches() {
2321
1310
  try {
@@ -2400,16 +1389,12 @@ async function switchToBranch(branchName, recordHistory = true) {
2400
1389
  const isDirty = await hasUncommittedChanges();
2401
1390
  if (isDirty) {
2402
1391
  addLog(`Cannot switch: uncommitted changes in working directory`, 'error');
2403
- addLog(`Commit or stash your changes first`, 'warning');
2404
- showErrorToast(
2405
- 'Cannot Switch Branch',
2406
- 'You have uncommitted changes in your working directory that would be lost.',
2407
- 'Run: git stash or git commit'
2408
- );
1392
+ pendingDirtyOperation = { type: 'switch', branch: branchName };
1393
+ showStashConfirm(`switch to ${branchName}`);
2409
1394
  return { success: false, reason: 'dirty' };
2410
1395
  }
2411
1396
 
2412
- const previousBranch = currentBranch;
1397
+ const previousBranch = store.get('currentBranch');
2413
1398
 
2414
1399
  addLog(`Switching to ${safeBranchName}...`, 'update');
2415
1400
  render();
@@ -2423,22 +1408,22 @@ async function switchToBranch(branchName, recordHistory = true) {
2423
1408
  await execAsync(`git checkout -b "${safeBranchName}" "${REMOTE_NAME}/${safeBranchName}"`);
2424
1409
  }
2425
1410
 
2426
- currentBranch = safeBranchName;
2427
- isDetachedHead = false; // Successfully switched to branch
1411
+ store.setState({ currentBranch: safeBranchName, isDetachedHead: false });
2428
1412
 
2429
1413
  // Clear NEW flag when branch becomes current
2430
- const branchInfo = branches.find(b => b.name === safeBranchName);
1414
+ const branchInfo = store.get('branches').find(b => b.name === safeBranchName);
2431
1415
  if (branchInfo && branchInfo.isNew) {
2432
1416
  branchInfo.isNew = false;
2433
1417
  }
2434
1418
 
2435
1419
  // Record in history (for undo)
2436
1420
  if (recordHistory && previousBranch && previousBranch !== safeBranchName) {
2437
- switchHistory.unshift({ from: previousBranch, to: safeBranchName, timestamp: Date.now() });
2438
- if (switchHistory.length > MAX_HISTORY) switchHistory.pop();
1421
+ const switchHistory = [{ from: previousBranch, to: safeBranchName, timestamp: Date.now() }, ...store.get('switchHistory')].slice(0, MAX_HISTORY);
1422
+ store.setState({ switchHistory });
2439
1423
  }
2440
1424
 
2441
1425
  addLog(`Switched to ${safeBranchName}`, 'success');
1426
+ pendingDirtyOperation = null;
2442
1427
 
2443
1428
  // Restart server if configured (command mode)
2444
1429
  if (SERVER_MODE === 'command' && RESTART_ON_SWITCH && serverProcess) {
@@ -2458,12 +1443,8 @@ async function switchToBranch(branchName, recordHistory = true) {
2458
1443
  );
2459
1444
  } else if (errMsg.includes('local changes') || errMsg.includes('overwritten')) {
2460
1445
  addLog(`Cannot switch: local changes would be overwritten`, 'error');
2461
- addLog(`Commit or stash your changes first`, 'warning');
2462
- showErrorToast(
2463
- 'Cannot Switch Branch',
2464
- 'Your local changes would be overwritten by checkout.',
2465
- 'Run: git stash or git commit'
2466
- );
1446
+ pendingDirtyOperation = { type: 'switch', branch: branchName };
1447
+ showStashConfirm(`switch to ${branchName}`);
2467
1448
  } else {
2468
1449
  addLog(`Failed to switch: ${errMsg}`, 'error');
2469
1450
  showErrorToast(
@@ -2477,17 +1458,18 @@ async function switchToBranch(branchName, recordHistory = true) {
2477
1458
  }
2478
1459
 
2479
1460
  async function undoLastSwitch() {
2480
- if (switchHistory.length === 0) {
1461
+ const currentHistory = store.get('switchHistory');
1462
+ if (currentHistory.length === 0) {
2481
1463
  addLog('No switch history to undo', 'warning');
2482
1464
  return { success: false };
2483
1465
  }
2484
1466
 
2485
- const lastSwitch = switchHistory[0];
1467
+ const lastSwitch = currentHistory[0];
2486
1468
  addLog(`Undoing: going back to ${lastSwitch.from}`, 'update');
2487
1469
 
2488
1470
  const result = await switchToBranch(lastSwitch.from, false);
2489
1471
  if (result.success) {
2490
- switchHistory.shift(); // Remove the undone entry
1472
+ store.setState({ switchHistory: store.get('switchHistory').slice(1) });
2491
1473
  addLog(`Undone: back on ${lastSwitch.from}`, 'success');
2492
1474
  }
2493
1475
  return result;
@@ -2514,14 +1496,18 @@ async function pullCurrentBranch() {
2514
1496
 
2515
1497
  await execAsync(`git pull "${REMOTE_NAME}" "${branch}"`);
2516
1498
  addLog('Pulled successfully', 'success');
1499
+ pendingDirtyOperation = null;
2517
1500
  notifyClients();
2518
1501
  return { success: true };
2519
1502
  } catch (e) {
2520
1503
  const errMsg = e.stderr || e.message || String(e);
2521
1504
  addLog(`Pull failed: ${errMsg}`, 'error');
2522
1505
 
2523
- if (isMergeConflict(errMsg)) {
2524
- hasMergeConflict = true;
1506
+ if (errMsg.includes('local changes') || errMsg.includes('overwritten') || errMsg.includes('uncommitted changes')) {
1507
+ pendingDirtyOperation = { type: 'pull' };
1508
+ showStashConfirm('pull');
1509
+ } else if (isMergeConflict(errMsg)) {
1510
+ store.setState({ hasMergeConflict: true });
2525
1511
  showErrorToast(
2526
1512
  'Merge Conflict!',
2527
1513
  'Git pull resulted in merge conflicts that need manual resolution.',
@@ -2550,17 +1536,78 @@ async function pullCurrentBranch() {
2550
1536
  }
2551
1537
  }
2552
1538
 
1539
+ async function stashAndRetry() {
1540
+ const operation = pendingDirtyOperation;
1541
+ if (!operation) {
1542
+ addLog('No pending operation to retry', 'warning');
1543
+ render();
1544
+ return;
1545
+ }
1546
+
1547
+ pendingDirtyOperation = null;
1548
+ hideErrorToast();
1549
+ hideStashConfirm();
1550
+
1551
+ addLog('Stashing uncommitted changes...', 'update');
1552
+ render();
1553
+
1554
+ const stashResult = await gitStash({ message: 'git-watchtower: auto-stash before ' + (operation.type === 'switch' ? `switching to ${operation.branch}` : 'pull') });
1555
+ if (!stashResult.success) {
1556
+ addLog(`Stash failed: ${stashResult.error ? stashResult.error.message : 'unknown error'}`, 'error');
1557
+ showErrorToast('Stash Failed', stashResult.error ? stashResult.error.message : 'Could not stash changes.');
1558
+ render();
1559
+ return;
1560
+ }
1561
+
1562
+ addLog('Changes stashed successfully', 'success');
1563
+
1564
+ if (operation.type === 'switch') {
1565
+ const switchResult = await switchToBranch(operation.branch);
1566
+ if (!switchResult.success) {
1567
+ addLog('Branch switch failed after stash — restoring stashed changes...', 'warning');
1568
+ const popResult = await gitStashPop();
1569
+ if (popResult.success) {
1570
+ addLog('Stashed changes restored', 'info');
1571
+ showFlash('Stashed changes restored (switch failed)');
1572
+ } else {
1573
+ addLog('Warning: could not restore stashed changes. Run: git stash pop', 'error');
1574
+ showErrorToast('Stash Pop Failed', 'Could not restore stashed changes.', 'Run: git stash pop');
1575
+ }
1576
+ } else {
1577
+ showFlash(`Stashed & switched to ${operation.branch}`);
1578
+ }
1579
+ await pollGitChanges();
1580
+ } else if (operation.type === 'pull') {
1581
+ const pullResult = await pullCurrentBranch();
1582
+ if (!pullResult.success) {
1583
+ addLog('Pull failed after stash — restoring stashed changes...', 'warning');
1584
+ const popResult = await gitStashPop();
1585
+ if (popResult.success) {
1586
+ addLog('Stashed changes restored', 'info');
1587
+ showFlash('Stashed changes restored (pull failed)');
1588
+ } else {
1589
+ addLog('Warning: could not restore stashed changes. Run: git stash pop', 'error');
1590
+ showErrorToast('Stash Pop Failed', 'Could not restore stashed changes.', 'Run: git stash pop');
1591
+ }
1592
+ } else {
1593
+ showFlash('Stashed & pulled successfully');
1594
+ }
1595
+ await pollGitChanges();
1596
+ }
1597
+
1598
+ render();
1599
+ }
1600
+
2553
1601
  // ============================================================================
2554
1602
  // Polling
2555
1603
  // ============================================================================
2556
1604
 
2557
1605
  async function pollGitChanges() {
2558
- if (isPolling) return;
2559
- isPolling = true;
2560
- pollingStatus = 'fetching';
1606
+ if (store.get('isPolling')) return;
1607
+ store.setState({ isPolling: true, pollingStatus: 'fetching' });
2561
1608
 
2562
1609
  // Casino mode: start slot reels spinning (no sound - too annoying)
2563
- if (casinoModeEnabled) {
1610
+ if (store.get('casinoModeEnabled')) {
2564
1611
  casino.startSlotReels(render);
2565
1612
  }
2566
1613
 
@@ -2570,25 +1617,28 @@ async function pollGitChanges() {
2570
1617
 
2571
1618
  try {
2572
1619
  const newCurrentBranch = await getCurrentBranch();
1620
+ const prevCurrentBranch = store.get('currentBranch');
2573
1621
 
2574
- if (currentBranch && newCurrentBranch !== currentBranch) {
2575
- addLog(`Branch switched externally: ${currentBranch} → ${newCurrentBranch}`, 'warning');
1622
+ if (prevCurrentBranch && newCurrentBranch !== prevCurrentBranch) {
1623
+ addLog(`Branch switched externally: ${prevCurrentBranch} → ${newCurrentBranch}`, 'warning');
2576
1624
  notifyClients();
2577
1625
  }
2578
- currentBranch = newCurrentBranch;
1626
+ store.setState({ currentBranch: newCurrentBranch });
2579
1627
 
2580
1628
  const allBranches = await getAllBranches();
2581
1629
 
2582
1630
  // Track fetch duration
2583
- lastFetchDuration = Date.now() - fetchStartTime;
1631
+ const lastFetchDuration = Date.now() - fetchStartTime;
1632
+ store.setState({ lastFetchDuration });
2584
1633
 
2585
1634
  // Check for slow fetches
2586
1635
  if (lastFetchDuration > 30000 && !verySlowFetchWarningShown) {
2587
1636
  addLog(`⚠ Fetches taking ${Math.round(lastFetchDuration / 1000)}s - network may be slow`, 'warning');
2588
1637
  verySlowFetchWarningShown = true;
2589
1638
  // Slow down polling
2590
- adaptivePollInterval = Math.min(adaptivePollInterval * 2, 60000);
2591
- addLog(`Polling interval increased to ${adaptivePollInterval / 1000}s`, 'info');
1639
+ const newInterval = Math.min(store.get('adaptivePollInterval') * 2, 60000);
1640
+ store.setState({ adaptivePollInterval: newInterval });
1641
+ addLog(`Polling interval increased to ${newInterval / 1000}s`, 'info');
2592
1642
  restartPolling();
2593
1643
  } else if (lastFetchDuration > 15000 && !slowFetchWarningShown) {
2594
1644
  addLog(`Fetches taking ${Math.round(lastFetchDuration / 1000)}s`, 'warning');
@@ -2597,21 +1647,22 @@ async function pollGitChanges() {
2597
1647
  // Reset warnings if fetches are fast again
2598
1648
  slowFetchWarningShown = false;
2599
1649
  verySlowFetchWarningShown = false;
2600
- if (adaptivePollInterval > GIT_POLL_INTERVAL) {
2601
- adaptivePollInterval = GIT_POLL_INTERVAL;
2602
- addLog(`Polling interval restored to ${adaptivePollInterval / 1000}s`, 'info');
1650
+ if (store.get('adaptivePollInterval') > GIT_POLL_INTERVAL) {
1651
+ store.setState({ adaptivePollInterval: GIT_POLL_INTERVAL });
1652
+ addLog(`Polling interval restored to ${GIT_POLL_INTERVAL / 1000}s`, 'info');
2603
1653
  restartPolling();
2604
1654
  }
2605
1655
  }
2606
1656
 
2607
1657
  // Network success - reset failure counter
2608
- consecutiveNetworkFailures = 0;
2609
- if (isOffline) {
2610
- isOffline = false;
1658
+ if (store.get('isOffline')) {
2611
1659
  addLog('Connection restored', 'success');
2612
1660
  }
1661
+ store.setState({ consecutiveNetworkFailures: 0, isOffline: false });
1662
+
2613
1663
  const fetchedBranchNames = new Set(allBranches.map(b => b.name));
2614
1664
  const now = Date.now();
1665
+ const currentBranches = store.get('branches');
2615
1666
 
2616
1667
  // Detect NEW branches (not seen before)
2617
1668
  const newBranchList = [];
@@ -2623,7 +1674,7 @@ async function pollGitChanges() {
2623
1674
  newBranchList.push(branch);
2624
1675
  } else {
2625
1676
  // Preserve isNew flag from previous poll cycle for branches not yet switched to
2626
- const prevBranch = branches.find(b => b.name === branch.name);
1677
+ const prevBranch = currentBranches.find(b => b.name === branch.name);
2627
1678
  if (prevBranch && prevBranch.isNew) {
2628
1679
  branch.isNew = true;
2629
1680
  branch.newAt = prevBranch.newAt;
@@ -2636,7 +1687,7 @@ async function pollGitChanges() {
2636
1687
  for (const knownName of knownBranchNames) {
2637
1688
  if (!fetchedBranchNames.has(knownName)) {
2638
1689
  // This branch was deleted from remote
2639
- const existingInList = branches.find(b => b.name === knownName);
1690
+ const existingInList = currentBranches.find(b => b.name === knownName);
2640
1691
  if (existingInList && !existingInList.isDeleted) {
2641
1692
  existingInList.isDeleted = true;
2642
1693
  existingInList.deletedAt = now;
@@ -2651,14 +1702,15 @@ async function pollGitChanges() {
2651
1702
  // Note: isNew flag is only cleared when branch becomes current (see below)
2652
1703
 
2653
1704
  // Keep deleted branches in the list (don't remove them)
2654
- const filteredBranches = allBranches;
1705
+ const pollFilteredBranches = allBranches;
2655
1706
 
2656
1707
  // Detect updates on other branches (for flash notification)
2657
1708
  const updatedBranches = [];
2658
- for (const branch of filteredBranches) {
1709
+ const currentBranchName = store.get('currentBranch');
1710
+ for (const branch of pollFilteredBranches) {
2659
1711
  if (branch.isDeleted) continue;
2660
1712
  const prevCommit = previousBranchStates.get(branch.name);
2661
- if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranch) {
1713
+ if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranchName) {
2662
1714
  updatedBranches.push(branch);
2663
1715
  branch.justUpdated = true;
2664
1716
  }
@@ -2666,6 +1718,7 @@ async function pollGitChanges() {
2666
1718
  }
2667
1719
 
2668
1720
  // Flash and sound for updates or new branches
1721
+ const casinoOn = store.get('casinoModeEnabled');
2669
1722
  const notifyBranches = [...updatedBranches, ...newBranchList];
2670
1723
  if (notifyBranches.length > 0) {
2671
1724
  for (const branch of updatedBranches) {
@@ -2673,7 +1726,7 @@ async function pollGitChanges() {
2673
1726
  }
2674
1727
 
2675
1728
  // Casino mode: add funny commentary
2676
- if (casinoModeEnabled) {
1729
+ if (casinoOn) {
2677
1730
  addLog(`🎰 ${getCasinoMessage('win')}`, 'success');
2678
1731
  }
2679
1732
 
@@ -2682,7 +1735,7 @@ async function pollGitChanges() {
2682
1735
  playSound();
2683
1736
 
2684
1737
  // Casino mode: trigger win effect based on number of updated branches
2685
- if (casinoModeEnabled) {
1738
+ if (casinoOn) {
2686
1739
  // Estimate line changes: more branches = bigger "win"
2687
1740
  // Each branch update counts as ~100 lines (placeholder until we calculate actual diff)
2688
1741
  const estimatedLines = notifyBranches.length * 100;
@@ -2694,21 +1747,23 @@ async function pollGitChanges() {
2694
1747
  }
2695
1748
  casino.recordPoll(true);
2696
1749
  }
2697
- } else if (casinoModeEnabled) {
1750
+ } else if (casinoOn) {
2698
1751
  // No updates - stop reels and show result briefly
2699
1752
  casino.stopSlotReels(false, render);
2700
1753
  casino.recordPoll(false);
2701
1754
  }
2702
1755
 
2703
1756
  // Remember which branch was selected before updating the list
2704
- const previouslySelectedName = selectedBranchName || (branches[selectedIndex] ? branches[selectedIndex].name : null);
1757
+ const { selectedBranchName: prevSelName, selectedIndex: prevSelIdx } = store.getState();
1758
+ const previouslySelectedName = prevSelName || (currentBranches[prevSelIdx] ? currentBranches[prevSelIdx].name : null);
2705
1759
 
2706
1760
  // Sort: new branches first, then by date, merged branches near bottom, deleted at bottom
2707
- filteredBranches.sort((a, b) => {
1761
+ const prStatusMap = store.get('branchPrStatusMap');
1762
+ pollFilteredBranches.sort((a, b) => {
2708
1763
  const aIsBase = isBaseBranch(a.name);
2709
1764
  const bIsBase = isBaseBranch(b.name);
2710
- const aMerged = !aIsBase && branchPrStatusMap.has(a.name) && branchPrStatusMap.get(a.name).state === 'MERGED';
2711
- const bMerged = !bIsBase && branchPrStatusMap.has(b.name) && branchPrStatusMap.get(b.name).state === 'MERGED';
1765
+ const aMerged = !aIsBase && prStatusMap.has(a.name) && prStatusMap.get(a.name).state === 'MERGED';
1766
+ const bMerged = !bIsBase && prStatusMap.has(b.name) && prStatusMap.get(b.name).state === 'MERGED';
2712
1767
  if (a.isDeleted && !b.isDeleted) return 1;
2713
1768
  if (!a.isDeleted && b.isDeleted) return -1;
2714
1769
  if (aMerged && !bMerged && !b.isDeleted) return 1;
@@ -2719,23 +1774,24 @@ async function pollGitChanges() {
2719
1774
  });
2720
1775
 
2721
1776
  // Store all branches (no limit) - visibleBranchCount controls display
2722
- branches = filteredBranches;
2723
-
2724
1777
  // Restore selection to the same branch (by name) after reordering
1778
+ let newSelectedIndex = prevSelIdx;
1779
+ let newSelectedName = prevSelName;
2725
1780
  if (previouslySelectedName) {
2726
- const newIndex = branches.findIndex(b => b.name === previouslySelectedName);
2727
- if (newIndex >= 0) {
2728
- selectedIndex = newIndex;
2729
- selectedBranchName = previouslySelectedName;
1781
+ const foundIdx = pollFilteredBranches.findIndex(b => b.name === previouslySelectedName);
1782
+ if (foundIdx >= 0) {
1783
+ newSelectedIndex = foundIdx;
1784
+ newSelectedName = previouslySelectedName;
2730
1785
  } else {
2731
1786
  // Branch fell off the list, keep index at bottom or clamp
2732
- selectedIndex = Math.min(selectedIndex, Math.max(0, branches.length - 1));
2733
- selectedBranchName = branches[selectedIndex] ? branches[selectedIndex].name : null;
1787
+ newSelectedIndex = Math.min(prevSelIdx, Math.max(0, pollFilteredBranches.length - 1));
1788
+ newSelectedName = pollFilteredBranches[newSelectedIndex] ? pollFilteredBranches[newSelectedIndex].name : null;
2734
1789
  }
2735
- } else if (selectedIndex >= branches.length) {
2736
- selectedIndex = Math.max(0, branches.length - 1);
2737
- selectedBranchName = branches[selectedIndex] ? branches[selectedIndex].name : null;
1790
+ } else if (prevSelIdx >= pollFilteredBranches.length) {
1791
+ newSelectedIndex = Math.max(0, pollFilteredBranches.length - 1);
1792
+ newSelectedName = pollFilteredBranches[newSelectedIndex] ? pollFilteredBranches[newSelectedIndex].name : null;
2738
1793
  }
1794
+ store.setState({ branches: pollFilteredBranches, selectedIndex: newSelectedIndex, selectedBranchName: newSelectedName });
2739
1795
 
2740
1796
  // Background PR status fetch (throttled to every PR_STATUS_POLL_INTERVAL)
2741
1797
  const now2 = Date.now();
@@ -2743,7 +1799,7 @@ async function pollGitChanges() {
2743
1799
  prStatusFetchInFlight = true;
2744
1800
  fetchAllPrStatuses().then(map => {
2745
1801
  if (map) {
2746
- branchPrStatusMap = map;
1802
+ store.setState({ branchPrStatusMap: map });
2747
1803
  render(); // re-render to show updated PR indicators
2748
1804
  }
2749
1805
  lastPrStatusFetch = Date.now();
@@ -2754,28 +1810,29 @@ async function pollGitChanges() {
2754
1810
  }
2755
1811
 
2756
1812
  // AUTO-PULL: If current branch has remote updates, pull automatically (if enabled)
2757
- const currentInfo = branches.find(b => b.name === currentBranch);
2758
- if (AUTO_PULL && currentInfo && currentInfo.hasUpdates && !hasMergeConflict) {
2759
- addLog(`Auto-pulling changes for ${currentBranch}...`, 'update');
1813
+ const autoPullBranchName = store.get('currentBranch');
1814
+ const currentInfo = store.get('branches').find(b => b.name === autoPullBranchName);
1815
+ if (AUTO_PULL && currentInfo && currentInfo.hasUpdates && !store.get('hasMergeConflict')) {
1816
+ addLog(`Auto-pulling changes for ${autoPullBranchName}...`, 'update');
2760
1817
  render();
2761
1818
 
2762
1819
  // Save the old commit for diff calculation (casino mode)
2763
1820
  const oldCommit = currentInfo.commit;
2764
1821
 
2765
1822
  try {
2766
- await execAsync(`git pull "${REMOTE_NAME}" "${currentBranch}"`);
2767
- addLog(`Pulled successfully from ${currentBranch}`, 'success');
1823
+ await execAsync(`git pull "${REMOTE_NAME}" "${autoPullBranchName}"`);
1824
+ addLog(`Pulled successfully from ${autoPullBranchName}`, 'success');
2768
1825
  currentInfo.hasUpdates = false;
2769
- hasMergeConflict = false;
1826
+ store.setState({ hasMergeConflict: false });
2770
1827
  // Update the stored commit to the new one
2771
1828
  const newCommit = await execAsync('git rev-parse --short HEAD');
2772
1829
  currentInfo.commit = newCommit.stdout.trim();
2773
- previousBranchStates.set(currentBranch, newCommit.stdout.trim());
1830
+ previousBranchStates.set(autoPullBranchName, newCommit.stdout.trim());
2774
1831
  // Reload browsers
2775
1832
  notifyClients();
2776
1833
 
2777
1834
  // Casino mode: calculate actual diff and trigger win effect
2778
- if (casinoModeEnabled && oldCommit) {
1835
+ if (store.get('casinoModeEnabled') && oldCommit) {
2779
1836
  const diffStats = await getDiffStats(oldCommit, 'HEAD');
2780
1837
  const totalLines = diffStats.added + diffStats.deleted;
2781
1838
  if (totalLines > 0) {
@@ -2790,7 +1847,7 @@ async function pollGitChanges() {
2790
1847
  } catch (e) {
2791
1848
  const errMsg = e.stderr || e.stdout || e.message || String(e);
2792
1849
  if (isMergeConflict(errMsg)) {
2793
- hasMergeConflict = true;
1850
+ store.setState({ hasMergeConflict: true });
2794
1851
  addLog(`MERGE CONFLICT detected!`, 'error');
2795
1852
  addLog(`Resolve conflicts manually, then commit`, 'warning');
2796
1853
  showErrorToast(
@@ -2799,7 +1856,7 @@ async function pollGitChanges() {
2799
1856
  'Run: git status to see conflicts'
2800
1857
  );
2801
1858
  // Casino mode: trigger loss effect
2802
- if (casinoModeEnabled) {
1859
+ if (store.get('casinoModeEnabled')) {
2803
1860
  casino.triggerLoss('MERGE CONFLICT!', render);
2804
1861
  casinoSounds.playLoss();
2805
1862
  addLog(`💀 ${getCasinoMessage('loss')}`, 'error');
@@ -2823,16 +1880,16 @@ async function pollGitChanges() {
2823
1880
  }
2824
1881
  }
2825
1882
 
2826
- pollingStatus = 'idle';
1883
+ store.setState({ pollingStatus: 'idle' });
2827
1884
  // Casino mode: stop slot reels if still spinning (already handled above, just cleanup)
2828
- if (casinoModeEnabled && casino.isSlotSpinning()) {
1885
+ if (store.get('casinoModeEnabled') && casino.isSlotSpinning()) {
2829
1886
  casino.stopSlotReels(false, render);
2830
1887
  }
2831
1888
  } catch (err) {
2832
1889
  const errMsg = err.stderr || err.message || String(err);
2833
1890
 
2834
1891
  // Casino mode: stop slot reels and show loss on error
2835
- if (casinoModeEnabled) {
1892
+ if (store.get('casinoModeEnabled')) {
2836
1893
  casino.stopSlotReels(false, render);
2837
1894
  casino.triggerLoss('BUST!', render);
2838
1895
  casinoSounds.playLoss();
@@ -2840,17 +1897,18 @@ async function pollGitChanges() {
2840
1897
 
2841
1898
  // Handle different error types
2842
1899
  if (isNetworkError(errMsg)) {
2843
- consecutiveNetworkFailures++;
2844
- if (consecutiveNetworkFailures >= 3 && !isOffline) {
2845
- isOffline = true;
2846
- addLog(`Network unavailable (${consecutiveNetworkFailures} failures)`, 'error');
1900
+ const failures = store.get('consecutiveNetworkFailures') + 1;
1901
+ store.setState({ consecutiveNetworkFailures: failures });
1902
+ if (failures >= 3 && !store.get('isOffline')) {
1903
+ store.setState({ isOffline: true });
1904
+ addLog(`Network unavailable (${failures} failures)`, 'error');
2847
1905
  showErrorToast(
2848
1906
  'Network Unavailable',
2849
1907
  'Cannot connect to the remote repository. Git operations will fail until connection is restored.',
2850
1908
  'Check your internet connection'
2851
1909
  );
2852
1910
  }
2853
- pollingStatus = 'error';
1911
+ store.setState({ pollingStatus: 'error' });
2854
1912
  } else if (isAuthError(errMsg)) {
2855
1913
  addLog(`Authentication error - check credentials`, 'error');
2856
1914
  addLog(`Try: git config credential.helper store`, 'warning');
@@ -2859,13 +1917,13 @@ async function pollGitChanges() {
2859
1917
  'Failed to authenticate with the remote repository.',
2860
1918
  'Run: git config credential.helper store'
2861
1919
  );
2862
- pollingStatus = 'error';
1920
+ store.setState({ pollingStatus: 'error' });
2863
1921
  } else {
2864
- pollingStatus = 'error';
1922
+ store.setState({ pollingStatus: 'error' });
2865
1923
  addLog(`Polling error: ${errMsg}`, 'error');
2866
1924
  }
2867
1925
  } finally {
2868
- isPolling = false;
1926
+ store.setState({ isPolling: false });
2869
1927
  render();
2870
1928
  }
2871
1929
  }
@@ -2874,7 +1932,7 @@ function restartPolling() {
2874
1932
  if (pollIntervalId) {
2875
1933
  clearInterval(pollIntervalId);
2876
1934
  }
2877
- pollIntervalId = setInterval(pollGitChanges, adaptivePollInterval);
1935
+ pollIntervalId = setInterval(pollGitChanges, store.get('adaptivePollInterval'));
2878
1936
  }
2879
1937
 
2880
1938
  // ============================================================================
@@ -3011,17 +2069,18 @@ function setupFileWatcher() {
3011
2069
  // Keyboard Input
3012
2070
  // ============================================================================
3013
2071
 
3014
- function applySearchFilter() {
3015
- if (!searchQuery) {
3016
- filteredBranches = null;
3017
- return;
3018
- }
3019
- const query = searchQuery.toLowerCase();
3020
- filteredBranches = branches.filter(b => b.name.toLowerCase().includes(query));
3021
- // Reset selection if out of bounds
3022
- if (selectedIndex >= filteredBranches.length) {
3023
- selectedIndex = Math.max(0, filteredBranches.length - 1);
3024
- }
2072
+ // applySearchFilter — replaced by filterBranches import (src/ui/renderer.js)
2073
+
2074
+ // Apply state updates from action handlers to store
2075
+ function applyUpdates(updates) {
2076
+ if (!updates) return false;
2077
+ store.setState(updates);
2078
+ return true;
2079
+ }
2080
+
2081
+ // Build current state snapshot for action handlers
2082
+ function getActionState() {
2083
+ return store.getState();
3025
2084
  }
3026
2085
 
3027
2086
  function setupKeyboardInput() {
@@ -3032,25 +2091,11 @@ function setupKeyboardInput() {
3032
2091
  process.stdin.setEncoding('utf8');
3033
2092
 
3034
2093
  process.stdin.on('data', async (key) => {
3035
- // Handle search mode input
3036
- if (searchMode) {
3037
- if (key === '\u001b' || key === '\r' || key === '\n') { // Escape or Enter exits search
3038
- searchMode = false;
3039
- if (key === '\u001b') {
3040
- // Escape clears search
3041
- searchQuery = '';
3042
- filteredBranches = null;
3043
- }
3044
- render();
3045
- return;
3046
- } else if (key === '\u007f' || key === '\b') { // Backspace
3047
- searchQuery = searchQuery.slice(0, -1);
3048
- applySearchFilter();
3049
- render();
3050
- return;
3051
- } else if (key.length === 1 && key >= ' ' && key <= '~') { // Printable chars
3052
- searchQuery += key;
3053
- applySearchFilter();
2094
+ // Handle search mode input via actions module
2095
+ if (store.get('searchMode')) {
2096
+ const searchResult = actions.handleSearchInput(getActionState(), key);
2097
+ if (searchResult) {
2098
+ applyUpdates(searchResult);
3054
2099
  render();
3055
2100
  return;
3056
2101
  }
@@ -3061,24 +2106,23 @@ function setupKeyboardInput() {
3061
2106
  }
3062
2107
 
3063
2108
  // Handle modal modes
3064
- if (previewMode) {
2109
+ if (store.get('previewMode')) {
3065
2110
  if (key === 'v' || key === '\u001b' || key === '\r' || key === '\n') {
3066
- previewMode = false;
3067
- previewData = null;
2111
+ applyUpdates(actions.togglePreview(getActionState()));
3068
2112
  render();
3069
2113
  return;
3070
2114
  }
3071
2115
  return; // Ignore other keys in preview mode
3072
2116
  }
3073
2117
 
3074
- if (historyMode) {
2118
+ if (store.get('historyMode')) {
3075
2119
  if (key === 'h' || key === '\u001b') {
3076
- historyMode = false;
2120
+ applyUpdates(actions.toggleHistory(getActionState()));
3077
2121
  render();
3078
2122
  return;
3079
2123
  }
3080
2124
  if (key === 'u') {
3081
- historyMode = false;
2125
+ store.setState({ historyMode: false });
3082
2126
  await undoLastSwitch();
3083
2127
  await pollGitChanges();
3084
2128
  return;
@@ -3086,44 +2130,38 @@ function setupKeyboardInput() {
3086
2130
  return; // Ignore other keys in history mode
3087
2131
  }
3088
2132
 
3089
- if (infoMode) {
2133
+ if (store.get('infoMode')) {
3090
2134
  if (key === 'i' || key === '\u001b') {
3091
- infoMode = false;
2135
+ applyUpdates(actions.toggleInfo(getActionState()));
3092
2136
  render();
3093
2137
  return;
3094
2138
  }
3095
2139
  return; // Ignore other keys in info mode
3096
2140
  }
3097
2141
 
3098
- if (logViewMode) {
2142
+ if (store.get('logViewMode')) {
3099
2143
  if (key === 'l' || key === '\u001b') {
3100
- logViewMode = false;
3101
- logScrollOffset = 0;
2144
+ applyUpdates(actions.toggleLogView(getActionState()));
3102
2145
  render();
3103
2146
  return;
3104
2147
  }
3105
2148
  if (key === '1') { // Switch to activity tab
3106
- logViewTab = 'activity';
3107
- logScrollOffset = 0;
2149
+ applyUpdates(actions.switchLogTab(getActionState(), 'activity'));
3108
2150
  render();
3109
2151
  return;
3110
2152
  }
3111
2153
  if (key === '2') { // Switch to server tab
3112
- logViewTab = 'server';
3113
- logScrollOffset = 0;
2154
+ applyUpdates(actions.switchLogTab(getActionState(), 'server'));
3114
2155
  render();
3115
2156
  return;
3116
2157
  }
3117
- // Get current log data for scroll bounds
3118
- const currentLogData = logViewTab === 'server' ? serverLogBuffer : activityLog;
3119
- const maxScroll = Math.max(0, currentLogData.length - 10);
3120
2158
  if (key === '\u001b[A' || key === 'k') { // Up - scroll
3121
- logScrollOffset = Math.min(logScrollOffset + 1, maxScroll);
2159
+ applyUpdates(actions.scrollLog(getActionState(), 'up'));
3122
2160
  render();
3123
2161
  return;
3124
2162
  }
3125
2163
  if (key === '\u001b[B' || key === 'j') { // Down - scroll
3126
- logScrollOffset = Math.max(0, logScrollOffset - 1);
2164
+ applyUpdates(actions.scrollLog(getActionState(), 'down'));
3127
2165
  render();
3128
2166
  return;
3129
2167
  }
@@ -3135,16 +2173,15 @@ function setupKeyboardInput() {
3135
2173
  return; // Ignore other keys in log view mode
3136
2174
  }
3137
2175
 
3138
- if (actionMode) {
2176
+ if (store.get('actionMode')) {
3139
2177
  if (key === '\u001b') { // Escape to close
3140
- actionMode = false;
3141
- actionData = null;
3142
- actionLoading = false;
2178
+ applyUpdates(actions.closeActionModal(getActionState()));
3143
2179
  render();
3144
2180
  return;
3145
2181
  }
3146
- if (!actionData) return;
3147
- const { branch: aBranch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, platform, prLoaded } = actionData;
2182
+ const currentActionData = store.get('actionData');
2183
+ if (!currentActionData) return;
2184
+ const { branch: aBranch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, platform, prLoaded } = currentActionData;
3148
2185
  const cliReady = (platform === 'gitlab') ? (hasGlab && glabAuthed) : (hasGh && ghAuthed);
3149
2186
  const prLabel = platform === 'gitlab' ? 'MR' : 'PR';
3150
2187
 
@@ -3185,13 +2222,12 @@ function setupKeyboardInput() {
3185
2222
  addLog(`${prLabel} created: ${(result.stdout || '').trim().split('\n').pop()}`, 'success');
3186
2223
  // Invalidate cache and refresh modal data
3187
2224
  prInfoCache.delete(aBranch.name);
3188
- actionData = gatherLocalActionData(aBranch);
3189
- actionLoading = true;
2225
+ const refreshedData = gatherLocalActionData(aBranch);
2226
+ store.setState({ actionData: refreshedData, actionLoading: true });
3190
2227
  render();
3191
- loadAsyncActionData(aBranch, actionData).then((fullData) => {
3192
- if (actionMode && actionData && actionData.branch.name === aBranch.name) {
3193
- actionData = fullData;
3194
- actionLoading = false;
2228
+ loadAsyncActionData(aBranch, refreshedData).then((fullData) => {
2229
+ if (store.get('actionMode') && store.get('actionData') && store.get('actionData').branch.name === aBranch.name) {
2230
+ store.setState({ actionData: fullData, actionLoading: false });
3195
2231
  render();
3196
2232
  }
3197
2233
  }).catch(() => {});
@@ -3243,9 +2279,7 @@ function setupKeyboardInput() {
3243
2279
  await execAsync(`gh pr merge ${prInfo.number} --squash --delete-branch 2>&1`);
3244
2280
  }
3245
2281
  addLog(`${prLabel} #${prInfo.number} merged`, 'success');
3246
- actionMode = false;
3247
- actionData = null;
3248
- actionLoading = false;
2282
+ store.setState({ actionMode: false, actionData: null, actionLoading: false });
3249
2283
  prInfoCache.delete(aBranch.name);
3250
2284
  // Force-refresh bulk PR statuses so inline indicators update immediately
3251
2285
  lastPrStatusFetch = 0;
@@ -3295,55 +2329,101 @@ function setupKeyboardInput() {
3295
2329
  return; // Ignore other keys in action mode
3296
2330
  }
3297
2331
 
2332
+ // Handle stash confirmation dialog
2333
+ if (store.get('stashConfirmMode')) {
2334
+ if (key === '\u001b[A' || key === 'k') { // Up
2335
+ const idx = store.get('stashConfirmSelectedIndex');
2336
+ if (idx > 0) {
2337
+ store.setState({ stashConfirmSelectedIndex: idx - 1 });
2338
+ render();
2339
+ }
2340
+ return;
2341
+ }
2342
+ if (key === '\u001b[B' || key === 'j') { // Down
2343
+ const idx = store.get('stashConfirmSelectedIndex');
2344
+ if (idx < 1) {
2345
+ store.setState({ stashConfirmSelectedIndex: idx + 1 });
2346
+ render();
2347
+ }
2348
+ return;
2349
+ }
2350
+ if (key === '\r' || key === '\n') { // Enter — execute selected option
2351
+ const idx = store.get('stashConfirmSelectedIndex');
2352
+ hideStashConfirm();
2353
+ if (idx === 0 && pendingDirtyOperation) {
2354
+ await stashAndRetry();
2355
+ } else {
2356
+ addLog('Stash cancelled — handle changes manually', 'info');
2357
+ pendingDirtyOperation = null;
2358
+ }
2359
+ return;
2360
+ }
2361
+ if (key === 'S') { // S shortcut — stash directly
2362
+ hideStashConfirm();
2363
+ if (pendingDirtyOperation) {
2364
+ await stashAndRetry();
2365
+ }
2366
+ return;
2367
+ }
2368
+ if (key === '\u001b') { // Escape — cancel
2369
+ hideStashConfirm();
2370
+ addLog('Stash cancelled — handle changes manually', 'info');
2371
+ pendingDirtyOperation = null;
2372
+ render();
2373
+ return;
2374
+ }
2375
+ return; // Ignore other keys in stash confirm mode
2376
+ }
2377
+
3298
2378
  // Dismiss flash on any key
3299
- if (flashMessage) {
2379
+ if (store.get('flashMessage')) {
3300
2380
  hideFlash();
3301
2381
  if (key !== '\u001b[A' && key !== '\u001b[B' && key !== '\r' && key !== 'q') {
3302
2382
  return;
3303
2383
  }
3304
2384
  }
3305
2385
 
3306
- // Dismiss error toast on any key
3307
- if (errorToast) {
2386
+ // Dismiss error toast on any key (S triggers stash if pending)
2387
+ if (store.get('errorToast')) {
2388
+ if (key === 'S' && pendingDirtyOperation) {
2389
+ await stashAndRetry();
2390
+ return;
2391
+ }
3308
2392
  hideErrorToast();
3309
2393
  if (key !== '\u001b[A' && key !== '\u001b[B' && key !== '\r' && key !== 'q') {
3310
2394
  return;
3311
2395
  }
3312
2396
  }
3313
2397
 
3314
- const displayBranches = filteredBranches !== null ? filteredBranches : branches;
2398
+ const { filteredBranches: currentFiltered, branches: currentBranchList, selectedIndex: curSelIdx } = store.getState();
2399
+ const displayBranches = currentFiltered !== null ? currentFiltered : currentBranchList;
2400
+ const actionState = getActionState();
3315
2401
 
3316
2402
  switch (key) {
3317
2403
  case '\u001b[A': // Up arrow
3318
- case 'k':
3319
- if (selectedIndex > 0) {
3320
- selectedIndex--;
3321
- selectedBranchName = displayBranches[selectedIndex] ? displayBranches[selectedIndex].name : null;
3322
- render();
3323
- }
2404
+ case 'k': {
2405
+ const result = actions.moveUp(actionState);
2406
+ if (result) { applyUpdates(result); render(); }
3324
2407
  break;
2408
+ }
3325
2409
 
3326
2410
  case '\u001b[B': // Down arrow
3327
- case 'j':
3328
- if (selectedIndex < displayBranches.length - 1) {
3329
- selectedIndex++;
3330
- selectedBranchName = displayBranches[selectedIndex] ? displayBranches[selectedIndex].name : null;
3331
- render();
3332
- }
2411
+ case 'j': {
2412
+ const result = actions.moveDown(actionState);
2413
+ if (result) { applyUpdates(result); render(); }
3333
2414
  break;
2415
+ }
3334
2416
 
3335
2417
  case '\r': // Enter
3336
2418
  case '\n':
3337
- if (displayBranches.length > 0 && selectedIndex < displayBranches.length) {
3338
- const branch = displayBranches[selectedIndex];
2419
+ if (displayBranches.length > 0 && curSelIdx < displayBranches.length) {
2420
+ const branch = displayBranches[curSelIdx];
3339
2421
  if (branch.isDeleted) {
3340
2422
  addLog(`Cannot switch to deleted branch: ${branch.name}`, 'error');
3341
2423
  render();
3342
- } else if (branch.name !== currentBranch) {
2424
+ } else if (branch.name !== store.get('currentBranch')) {
3343
2425
  // Clear search when switching
3344
- searchQuery = '';
3345
- filteredBranches = null;
3346
- searchMode = false;
2426
+ store.setState({ searchQuery: '', filteredBranches: null, searchMode: false });
3347
2427
  await switchToBranch(branch.name);
3348
2428
  await pollGitChanges();
3349
2429
  }
@@ -3351,30 +2431,28 @@ function setupKeyboardInput() {
3351
2431
  break;
3352
2432
 
3353
2433
  case 'v': // Preview pane
3354
- if (displayBranches.length > 0 && selectedIndex < displayBranches.length) {
3355
- const branch = displayBranches[selectedIndex];
2434
+ if (displayBranches.length > 0 && curSelIdx < displayBranches.length) {
2435
+ const branch = displayBranches[curSelIdx];
3356
2436
  addLog(`Loading preview for ${branch.name}...`, 'info');
3357
2437
  render();
3358
- previewData = await getPreviewData(branch.name);
3359
- previewMode = true;
2438
+ const pvData = await getPreviewData(branch.name);
2439
+ store.setState({ previewData: pvData, previewMode: true });
3360
2440
  render();
3361
2441
  }
3362
2442
  break;
3363
2443
 
3364
2444
  case '/': // Search mode
3365
- searchMode = true;
3366
- searchQuery = '';
3367
- selectedIndex = 0;
2445
+ applyUpdates(actions.enterSearchMode(actionState));
3368
2446
  render();
3369
2447
  break;
3370
2448
 
3371
2449
  case 'h': // History
3372
- historyMode = true;
2450
+ applyUpdates(actions.toggleHistory(actionState));
3373
2451
  render();
3374
2452
  break;
3375
2453
 
3376
2454
  case 'i': // Server info
3377
- infoMode = true;
2455
+ applyUpdates(actions.toggleInfo(actionState));
3378
2456
  render();
3379
2457
  break;
3380
2458
 
@@ -3403,13 +2481,11 @@ function setupKeyboardInput() {
3403
2481
  }
3404
2482
  break;
3405
2483
 
3406
- case 'l': // View server logs
3407
- if (!NO_SERVER) {
3408
- logViewMode = true;
3409
- logScrollOffset = 0;
3410
- render();
3411
- }
2484
+ case 'l': { // View server logs
2485
+ const logResult = actions.toggleLogView(actionState);
2486
+ if (logResult) { applyUpdates(logResult); render(); }
3412
2487
  break;
2488
+ }
3413
2489
 
3414
2490
  case 'o': // Open live server in browser
3415
2491
  if (!NO_SERVER) {
@@ -3421,26 +2497,24 @@ function setupKeyboardInput() {
3421
2497
  break;
3422
2498
 
3423
2499
  case 'b': { // Branch action modal
3424
- const branch = displayBranches.length > 0 && selectedIndex < displayBranches.length
3425
- ? displayBranches[selectedIndex] : null;
2500
+ const branch = displayBranches.length > 0 && curSelIdx < displayBranches.length
2501
+ ? displayBranches[curSelIdx] : null;
3426
2502
  if (branch) {
3427
2503
  // Phase 1: Open modal instantly with local/cached data
3428
- actionData = gatherLocalActionData(branch);
3429
- actionMode = true;
3430
- actionLoading = !actionData.prLoaded;
2504
+ const localData = gatherLocalActionData(branch);
2505
+ store.setState({ actionData: localData, actionMode: true, actionLoading: !localData.prLoaded });
3431
2506
  render();
3432
2507
 
3433
2508
  // Phase 2: Load async data (session URL, PR info) in background
3434
- loadAsyncActionData(branch, actionData).then((fullData) => {
2509
+ loadAsyncActionData(branch, localData).then((fullData) => {
3435
2510
  // Only update if modal is still open for the same branch
3436
- if (actionMode && actionData && actionData.branch.name === branch.name) {
3437
- actionData = fullData;
3438
- actionLoading = false;
2511
+ if (store.get('actionMode') && store.get('actionData') && store.get('actionData').branch.name === branch.name) {
2512
+ store.setState({ actionData: fullData, actionLoading: false });
3439
2513
  render();
3440
2514
  }
3441
2515
  }).catch(() => {
3442
- if (actionMode && actionData && actionData.branch.name === branch.name) {
3443
- actionLoading = false;
2516
+ if (store.get('actionMode') && store.get('actionData') && store.get('actionData').branch.name === branch.name) {
2517
+ store.setState({ actionLoading: false });
3444
2518
  render();
3445
2519
  }
3446
2520
  });
@@ -3458,71 +2532,90 @@ function setupKeyboardInput() {
3458
2532
  render();
3459
2533
  break;
3460
2534
 
3461
- case 's':
3462
- soundEnabled = !soundEnabled;
3463
- addLog(`Sound notifications ${soundEnabled ? 'enabled' : 'disabled'}`, 'info');
3464
- if (soundEnabled) playSound();
2535
+ case 's': {
2536
+ applyUpdates(actions.toggleSound(actionState));
2537
+ addLog(`Sound notifications ${store.get('soundEnabled') ? 'enabled' : 'disabled'}`, 'info');
2538
+ if (store.get('soundEnabled')) playSound();
3465
2539
  render();
3466
2540
  break;
2541
+ }
2542
+
2543
+ case 'S': // Stash changes — open confirm dialog or show hint
2544
+ if (pendingDirtyOperation) {
2545
+ const label = pendingDirtyOperation.type === 'switch'
2546
+ ? `switch to ${pendingDirtyOperation.branch}`
2547
+ : 'pull';
2548
+ showStashConfirm(label);
2549
+ } else {
2550
+ showFlash('No pending operation — stash with S after a failed switch or pull');
2551
+ }
2552
+ break;
3467
2553
 
3468
- case 'c': // Toggle casino mode
3469
- casinoModeEnabled = casino.toggle();
3470
- addLog(`Casino mode ${casinoModeEnabled ? '🎰 ENABLED' : 'disabled'}`, casinoModeEnabled ? 'success' : 'info');
3471
- if (casinoModeEnabled) {
2554
+ case 'c': { // Toggle casino mode
2555
+ const newCasinoState = casino.toggle();
2556
+ store.setState({ casinoModeEnabled: newCasinoState });
2557
+ addLog(`Casino mode ${newCasinoState ? '🎰 ENABLED' : 'disabled'}`, newCasinoState ? 'success' : 'info');
2558
+ if (newCasinoState) {
3472
2559
  addLog(`Have you noticed this game has that 'variable rewards' thing going on? 🤔😉`, 'info');
3473
- if (soundEnabled) {
2560
+ if (store.get('soundEnabled')) {
3474
2561
  casinoSounds.playJackpot();
3475
2562
  }
3476
2563
  }
3477
2564
  render();
3478
2565
  break;
2566
+ }
3479
2567
 
3480
2568
  // Number keys to set visible branch count
3481
2569
  case '1': case '2': case '3': case '4': case '5':
3482
2570
  case '6': case '7': case '8': case '9':
3483
- visibleBranchCount = parseInt(key, 10);
3484
- addLog(`Showing ${visibleBranchCount} branches`, 'info');
2571
+ applyUpdates(actions.setVisibleBranchCount(actionState, parseInt(key, 10)));
2572
+ addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
3485
2573
  render();
3486
2574
  break;
3487
2575
 
3488
2576
  case '0': // 0 = 10 branches
3489
- visibleBranchCount = 10;
3490
- addLog(`Showing ${visibleBranchCount} branches`, 'info');
2577
+ applyUpdates(actions.setVisibleBranchCount(actionState, 10));
2578
+ addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
3491
2579
  render();
3492
2580
  break;
3493
2581
 
3494
2582
  case '+':
3495
- case '=': // = key (same key as + without shift)
3496
- if (visibleBranchCount < getMaxBranchesForScreen()) {
3497
- visibleBranchCount++;
3498
- addLog(`Showing ${visibleBranchCount} branches`, 'info');
2583
+ case '=': { // = key (same key as + without shift)
2584
+ const incResult = actions.increaseVisibleBranches(actionState, getMaxBranchesForScreen());
2585
+ if (incResult) {
2586
+ applyUpdates(incResult);
2587
+ addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
3499
2588
  render();
3500
2589
  }
3501
2590
  break;
2591
+ }
3502
2592
 
3503
2593
  case '-':
3504
- case '_': // _ key (same key as - with shift)
3505
- if (visibleBranchCount > 1) {
3506
- visibleBranchCount--;
3507
- addLog(`Showing ${visibleBranchCount} branches`, 'info');
2594
+ case '_': { // _ key (same key as - with shift)
2595
+ const decResult = actions.decreaseVisibleBranches(actionState);
2596
+ if (decResult) {
2597
+ applyUpdates(decResult);
2598
+ addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
3508
2599
  render();
3509
2600
  }
3510
2601
  break;
2602
+ }
3511
2603
 
3512
2604
  case 'q':
3513
2605
  case '\u0003': // Ctrl+C
3514
2606
  await shutdown();
3515
2607
  break;
3516
2608
 
3517
- case '\u001b': // Escape - clear search if active, otherwise quit
3518
- if (searchQuery || filteredBranches) {
3519
- searchQuery = '';
3520
- filteredBranches = null;
3521
- render();
3522
- } else {
2609
+ case '\u001b': { // Escape - clear search if active, otherwise quit
2610
+ const escResult = actions.handleEscape(actionState);
2611
+ if (escResult && escResult._quit) {
3523
2612
  await shutdown();
2613
+ } else if (escResult) {
2614
+ applyUpdates(escResult);
2615
+ render();
3524
2616
  }
3525
2617
  break;
2618
+ }
3526
2619
  }
3527
2620
  });
3528
2621
  }
@@ -3621,27 +2714,28 @@ async function start() {
3621
2714
  }
3622
2715
 
3623
2716
  // Get initial state
3624
- currentBranch = await getCurrentBranch();
2717
+ const initBranch = await getCurrentBranch();
2718
+ store.setState({ currentBranch: initBranch });
3625
2719
 
3626
2720
  // Warn if in detached HEAD state
3627
- if (isDetachedHead) {
2721
+ if (store.get('isDetachedHead')) {
3628
2722
  addLog(`Warning: In detached HEAD state`, 'warning');
3629
2723
  }
3630
- branches = await getAllBranches();
2724
+ const initBranches = await getAllBranches();
2725
+ store.setState({ branches: initBranches });
3631
2726
 
3632
2727
  // Initialize previous states and known branches
3633
- for (const branch of branches) {
2728
+ for (const branch of initBranches) {
3634
2729
  previousBranchStates.set(branch.name, branch.commit);
3635
2730
  knownBranchNames.add(branch.name);
3636
2731
  }
3637
2732
 
3638
2733
  // Find current branch in list and select it
3639
- const currentIndex = branches.findIndex(b => b.name === currentBranch);
2734
+ const currentIndex = initBranches.findIndex(b => b.name === initBranch);
3640
2735
  if (currentIndex >= 0) {
3641
- selectedIndex = currentIndex;
3642
- selectedBranchName = currentBranch;
3643
- } else if (branches.length > 0) {
3644
- selectedBranchName = branches[0].name;
2736
+ store.setState({ selectedIndex: currentIndex, selectedBranchName: initBranch });
2737
+ } else if (initBranches.length > 0) {
2738
+ store.setState({ selectedBranchName: initBranches[0].name });
3645
2739
  }
3646
2740
 
3647
2741
  // Load sparklines and action cache in background
@@ -3650,7 +2744,7 @@ async function start() {
3650
2744
  // Once env is known, kick off initial PR status fetch
3651
2745
  fetchAllPrStatuses().then(map => {
3652
2746
  if (map) {
3653
- branchPrStatusMap = map;
2747
+ store.setState({ branchPrStatusMap: map });
3654
2748
  lastPrStatusFetch = Date.now();
3655
2749
  render();
3656
2750
  }
@@ -3658,13 +2752,14 @@ async function start() {
3658
2752
  }).catch(() => {});
3659
2753
 
3660
2754
  // Start server based on mode
2755
+ const startBranchName = store.get('currentBranch');
3661
2756
  if (SERVER_MODE === 'none') {
3662
2757
  addLog(`Running in no-server mode (branch monitoring only)`, 'info');
3663
- addLog(`Current branch: ${currentBranch}`, 'info');
2758
+ addLog(`Current branch: ${startBranchName}`, 'info');
3664
2759
  render();
3665
2760
  } else if (SERVER_MODE === 'command') {
3666
2761
  addLog(`Command mode: ${SERVER_COMMAND}`, 'info');
3667
- addLog(`Current branch: ${currentBranch}`, 'info');
2762
+ addLog(`Current branch: ${startBranchName}`, 'info');
3668
2763
  render();
3669
2764
  // Start the user's dev server
3670
2765
  startServerProcess();
@@ -3673,7 +2768,7 @@ async function start() {
3673
2768
  server.listen(PORT, () => {
3674
2769
  addLog(`Server started on http://localhost:${PORT}`, 'success');
3675
2770
  addLog(`Serving ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`, 'info');
3676
- addLog(`Current branch: ${currentBranch}`, 'info');
2771
+ addLog(`Current branch: ${store.get('currentBranch')}`, 'info');
3677
2772
  // Add server log entries for static server
3678
2773
  addServerLog(`Static server started on http://localhost:${PORT}`);
3679
2774
  addServerLog(`Serving files from: ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`);
@@ -3707,7 +2802,7 @@ async function start() {
3707
2802
  });
3708
2803
 
3709
2804
  // Start polling with adaptive interval
3710
- pollIntervalId = setInterval(pollGitChanges, adaptivePollInterval);
2805
+ pollIntervalId = setInterval(pollGitChanges, store.get('adaptivePollInterval'));
3711
2806
 
3712
2807
  // Initial render
3713
2808
  render();