@akiojin/gwt 2.0.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 (132) hide show
  1. package/README.ja.md +323 -0
  2. package/README.md +347 -0
  3. package/bin/gwt.js +5 -0
  4. package/package.json +125 -0
  5. package/src/claude-history.ts +717 -0
  6. package/src/claude.ts +292 -0
  7. package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
  8. package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
  9. package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
  10. package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
  11. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
  12. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
  13. package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
  14. package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
  15. package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
  16. package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
  17. package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
  18. package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
  19. package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
  20. package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
  21. package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
  22. package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
  23. package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
  24. package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
  25. package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
  26. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
  27. package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
  28. package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
  29. package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
  30. package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
  31. package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
  32. package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
  33. package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
  34. package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
  35. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
  36. package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
  37. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
  38. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
  39. package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
  40. package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
  41. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
  42. package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
  43. package/src/cli/ui/components/App.tsx +793 -0
  44. package/src/cli/ui/components/common/Confirm.tsx +40 -0
  45. package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
  46. package/src/cli/ui/components/common/Input.tsx +36 -0
  47. package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
  48. package/src/cli/ui/components/common/Select.tsx +216 -0
  49. package/src/cli/ui/components/parts/Footer.tsx +41 -0
  50. package/src/cli/ui/components/parts/Header.test.tsx +85 -0
  51. package/src/cli/ui/components/parts/Header.tsx +63 -0
  52. package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
  53. package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
  54. package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
  55. package/src/cli/ui/components/parts/Stats.tsx +67 -0
  56. package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
  57. package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
  58. package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
  59. package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
  61. package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
  62. package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
  63. package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
  64. package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
  65. package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
  66. package/src/cli/ui/hooks/useGitData.ts +157 -0
  67. package/src/cli/ui/hooks/useScreenState.ts +44 -0
  68. package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
  69. package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
  70. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
  71. package/src/cli/ui/types.ts +295 -0
  72. package/src/cli/ui/utils/baseBranch.ts +34 -0
  73. package/src/cli/ui/utils/branchFormatter.ts +222 -0
  74. package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
  75. package/src/codex.ts +139 -0
  76. package/src/config/builtin-tools.ts +44 -0
  77. package/src/config/constants.ts +100 -0
  78. package/src/config/env-history.ts +45 -0
  79. package/src/config/index.ts +204 -0
  80. package/src/config/tools.ts +293 -0
  81. package/src/git.ts +1102 -0
  82. package/src/github.ts +158 -0
  83. package/src/index.test.ts +87 -0
  84. package/src/index.ts +684 -0
  85. package/src/index.ts.backup +1543 -0
  86. package/src/launcher.ts +142 -0
  87. package/src/repositories/git.repository.ts +129 -0
  88. package/src/repositories/github.repository.ts +83 -0
  89. package/src/repositories/worktree.repository.ts +69 -0
  90. package/src/services/BatchMergeService.ts +251 -0
  91. package/src/services/WorktreeOrchestrator.ts +115 -0
  92. package/src/services/__tests__/BatchMergeService.test.ts +518 -0
  93. package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
  94. package/src/services/dependency-installer.ts +199 -0
  95. package/src/services/git.service.ts +113 -0
  96. package/src/services/github.service.ts +61 -0
  97. package/src/services/worktree.service.ts +66 -0
  98. package/src/types/api.ts +241 -0
  99. package/src/types/tools.ts +235 -0
  100. package/src/utils/spinner.ts +54 -0
  101. package/src/utils/terminal.ts +272 -0
  102. package/src/utils.test.ts +43 -0
  103. package/src/utils.ts +60 -0
  104. package/src/web/client/index.html +12 -0
  105. package/src/web/client/src/components/BranchGraph.tsx +231 -0
  106. package/src/web/client/src/components/EnvEditor.tsx +145 -0
  107. package/src/web/client/src/components/Terminal.tsx +137 -0
  108. package/src/web/client/src/hooks/useBranches.ts +41 -0
  109. package/src/web/client/src/hooks/useConfig.ts +31 -0
  110. package/src/web/client/src/hooks/useSessions.ts +59 -0
  111. package/src/web/client/src/hooks/useWorktrees.ts +47 -0
  112. package/src/web/client/src/index.css +834 -0
  113. package/src/web/client/src/lib/api.ts +184 -0
  114. package/src/web/client/src/lib/websocket.ts +174 -0
  115. package/src/web/client/src/main.tsx +29 -0
  116. package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
  117. package/src/web/client/src/pages/BranchListPage.tsx +264 -0
  118. package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
  119. package/src/web/client/src/router.tsx +27 -0
  120. package/src/web/client/vite.config.ts +21 -0
  121. package/src/web/server/env/importer.ts +54 -0
  122. package/src/web/server/index.ts +74 -0
  123. package/src/web/server/pty/manager.ts +189 -0
  124. package/src/web/server/routes/branches.ts +126 -0
  125. package/src/web/server/routes/config.ts +220 -0
  126. package/src/web/server/routes/index.ts +37 -0
  127. package/src/web/server/routes/sessions.ts +130 -0
  128. package/src/web/server/routes/worktrees.ts +108 -0
  129. package/src/web/server/services/branches.ts +368 -0
  130. package/src/web/server/services/worktrees.ts +85 -0
  131. package/src/web/server/websocket/handler.ts +180 -0
  132. package/src/worktree.ts +703 -0
@@ -0,0 +1,793 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useApp } from 'ink';
3
+ import { ErrorBoundary } from './common/ErrorBoundary.js';
4
+ import { BranchListScreen } from './screens/BranchListScreen.js';
5
+ import { WorktreeManagerScreen } from './screens/WorktreeManagerScreen.js';
6
+ import { BranchCreatorScreen } from './screens/BranchCreatorScreen.js';
7
+ import { BranchActionSelectorScreen } from '../screens/BranchActionSelectorScreen.js';
8
+ import { AIToolSelectorScreen } from './screens/AIToolSelectorScreen.js';
9
+ import { SessionSelectorScreen } from './screens/SessionSelectorScreen.js';
10
+ import { ExecutionModeSelectorScreen } from './screens/ExecutionModeSelectorScreen.js';
11
+ import type { AITool } from './screens/AIToolSelectorScreen.js';
12
+ import type { ExecutionMode } from './screens/ExecutionModeSelectorScreen.js';
13
+ import type { WorktreeItem } from './screens/WorktreeManagerScreen.js';
14
+ import { useGitData } from '../hooks/useGitData.js';
15
+ import { useScreenState } from '../hooks/useScreenState.js';
16
+ import { formatBranchItems } from '../utils/branchFormatter.js';
17
+ import { calculateStatistics } from '../utils/statisticsCalculator.js';
18
+ import type { BranchInfo, BranchItem, SelectedBranchState } from '../types.js';
19
+ import { getRepositoryRoot, deleteBranch } from '../../../git.js';
20
+ import {
21
+ createWorktree,
22
+ generateWorktreePath,
23
+ getMergedPRWorktrees,
24
+ isProtectedBranchName,
25
+ removeWorktree,
26
+ switchToProtectedBranch,
27
+ } from '../../../worktree.js';
28
+ import { getPackageVersion } from '../../../utils.js';
29
+ import {
30
+ resolveBaseBranchLabel,
31
+ resolveBaseBranchRef,
32
+ } from '../utils/baseBranch.js';
33
+
34
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
35
+ const COMPLETION_HOLD_DURATION_MS = 3000;
36
+ const PROTECTED_BRANCH_WARNING =
37
+ 'Root branches operate directly in the repository root. Create a new branch if you need a dedicated worktree.';
38
+
39
+ const getSpinnerFrame = (index: number): string => {
40
+ const frame = SPINNER_FRAMES[index];
41
+ if (typeof frame === 'string') {
42
+ return frame;
43
+ }
44
+ return SPINNER_FRAMES[0] ?? '⠋';
45
+ };
46
+
47
+ export interface SelectionResult {
48
+ branch: string; // Local branch name (without remote prefix)
49
+ displayName: string; // Name that was selected in the UI (may include remote prefix)
50
+ branchType: 'local' | 'remote';
51
+ remoteBranch?: string; // Full remote ref when branchType === 'remote'
52
+ tool: AITool;
53
+ mode: ExecutionMode;
54
+ skipPermissions: boolean;
55
+ }
56
+
57
+ export interface AppProps {
58
+ onExit: (result?: SelectionResult) => void;
59
+ loadingIndicatorDelay?: number;
60
+ }
61
+
62
+ /**
63
+ * App - Top-level component for Ink.js UI
64
+ * Integrates ErrorBoundary, data fetching, screen navigation, and all screens
65
+ */
66
+ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
67
+ const { exit } = useApp();
68
+
69
+ // 起動ディレクトリの取得
70
+ const workingDirectory = process.cwd();
71
+
72
+ const { branches, worktrees, loading, error, refresh, lastUpdated } = useGitData({
73
+ enableAutoRefresh: false, // Manual refresh with 'r' key
74
+ });
75
+ const { currentScreen, navigateTo, goBack } = useScreenState();
76
+
77
+ // Version state
78
+ const [version, setVersion] = useState<string | null>(null);
79
+
80
+ // Selection state (for branch → tool → mode flow)
81
+ const [selectedBranch, setSelectedBranch] = useState<SelectedBranchState | null>(null);
82
+ const [creationSourceBranch, setCreationSourceBranch] = useState<SelectedBranchState | null>(null);
83
+ const [selectedTool, setSelectedTool] = useState<AITool | null>(null);
84
+
85
+ // PR cleanup feedback
86
+ const [cleanupIndicators, setCleanupIndicators] = useState<Record<string, { icon: string; color?: 'cyan' | 'green' | 'yellow' | 'red' }>>({});
87
+ const [cleanupProcessingBranch, setCleanupProcessingBranch] = useState<string | null>(null);
88
+ const [cleanupInputLocked, setCleanupInputLocked] = useState(false);
89
+ const [cleanupFooterMessage, setCleanupFooterMessage] = useState<{ text: string; color?: 'cyan' | 'green' | 'yellow' | 'red' } | null>(null);
90
+ const [hiddenBranches, setHiddenBranches] = useState<string[]>([]);
91
+ const spinnerFrameIndexRef = useRef(0);
92
+ const [spinnerFrameIndex, setSpinnerFrameIndex] = useState(0);
93
+ const completionTimerRef = useRef<NodeJS.Timeout | null>(null);
94
+
95
+ // Fetch version on mount
96
+ useEffect(() => {
97
+ getPackageVersion()
98
+ .then(setVersion)
99
+ .catch(() => setVersion(null));
100
+ }, []);
101
+
102
+ useEffect(() => {
103
+ if (!cleanupInputLocked) {
104
+ spinnerFrameIndexRef.current = 0;
105
+ setSpinnerFrameIndex(0);
106
+ return undefined;
107
+ }
108
+
109
+ const interval = setInterval(() => {
110
+ spinnerFrameIndexRef.current = (spinnerFrameIndexRef.current + 1) % SPINNER_FRAMES.length;
111
+ setSpinnerFrameIndex(spinnerFrameIndexRef.current);
112
+ }, 120);
113
+
114
+ return () => {
115
+ clearInterval(interval);
116
+ spinnerFrameIndexRef.current = 0;
117
+ setSpinnerFrameIndex(0);
118
+ };
119
+ }, [cleanupInputLocked]);
120
+
121
+ useEffect(() => {
122
+ if (!cleanupInputLocked) {
123
+ return;
124
+ }
125
+
126
+ const frame = getSpinnerFrame(spinnerFrameIndex);
127
+
128
+ if (cleanupProcessingBranch) {
129
+ setCleanupIndicators((prev) => {
130
+ const current = prev[cleanupProcessingBranch];
131
+ if (current && current.icon === frame && current.color === 'cyan') {
132
+ return prev;
133
+ }
134
+
135
+ const next: Record<string, { icon: string; color?: 'cyan' | 'green' | 'yellow' | 'red' }> = {
136
+ ...prev,
137
+ [cleanupProcessingBranch]: { icon: frame, color: 'cyan' },
138
+ };
139
+
140
+ return next;
141
+ });
142
+ }
143
+
144
+ setCleanupFooterMessage({ text: `Processing... ${frame}`, color: 'cyan' });
145
+ }, [cleanupInputLocked, cleanupProcessingBranch, spinnerFrameIndex]);
146
+
147
+ useEffect(() => {
148
+ if (!hiddenBranches.length) {
149
+ return;
150
+ }
151
+
152
+ const existing = new Set(branches.map((branch) => branch.name));
153
+ const filtered = hiddenBranches.filter((name) => existing.has(name));
154
+
155
+ if (filtered.length !== hiddenBranches.length) {
156
+ setHiddenBranches(filtered);
157
+ }
158
+ }, [branches, hiddenBranches]);
159
+
160
+ useEffect(() => () => {
161
+ if (completionTimerRef.current) {
162
+ clearTimeout(completionTimerRef.current);
163
+ completionTimerRef.current = null;
164
+ }
165
+ }, []);
166
+
167
+ const visibleBranches = useMemo(
168
+ () => branches.filter((branch) => !hiddenBranches.includes(branch.name)),
169
+ [branches, hiddenBranches]
170
+ );
171
+
172
+ // Helper function to create content-based hash for branches
173
+ const branchHash = useMemo(
174
+ () => visibleBranches.map((b) => `${b.name}-${b.type}-${b.isCurrent}`).join(','),
175
+ [visibleBranches]
176
+ );
177
+
178
+ // Helper function to create content-based hash for worktrees
179
+ const worktreeHash = useMemo(
180
+ () => worktrees.map((w) => `${w.branch}-${w.path}`).join(','),
181
+ [worktrees]
182
+ );
183
+
184
+ // Format branches to BranchItems (memoized for performance with content-based dependencies)
185
+ const branchItems: BranchItem[] = useMemo(() => {
186
+ // Build worktreeMap for sorting
187
+ const worktreeMap = new Map();
188
+ for (const wt of worktrees) {
189
+ worktreeMap.set(wt.branch, {
190
+ path: wt.path,
191
+ locked: false,
192
+ prunable: wt.isAccessible === false,
193
+ isAccessible: wt.isAccessible ?? true,
194
+ });
195
+ }
196
+ return formatBranchItems(visibleBranches, worktreeMap);
197
+ }, [branchHash, worktreeHash, visibleBranches, worktrees]);
198
+
199
+ // Calculate statistics (memoized for performance)
200
+ const stats = useMemo(() => calculateStatistics(visibleBranches), [visibleBranches]);
201
+
202
+ // Format worktrees to WorktreeItems
203
+ const worktreeItems: WorktreeItem[] = useMemo(
204
+ () =>
205
+ worktrees.map((wt): WorktreeItem => ({
206
+ branch: wt.branch,
207
+ path: wt.path,
208
+ isAccessible: wt.isAccessible ?? true,
209
+ })),
210
+ [worktrees]
211
+ );
212
+
213
+ const resolveBaseBranch = useCallback(() => {
214
+ const localMain = branches.find(
215
+ (branch) =>
216
+ branch.type === 'local' && (branch.name === 'main' || branch.name === 'master')
217
+ );
218
+ if (localMain) {
219
+ return localMain.name;
220
+ }
221
+
222
+ const develop = branches.find(
223
+ (branch) => branch.type === 'local' && (branch.name === 'develop' || branch.name === 'dev')
224
+ );
225
+ if (develop) {
226
+ return develop.name;
227
+ }
228
+
229
+ return 'main';
230
+ }, [branches]);
231
+
232
+ const baseBranchLabel = useMemo(
233
+ () => resolveBaseBranchLabel(creationSourceBranch, selectedBranch, resolveBaseBranch),
234
+ [creationSourceBranch, resolveBaseBranch, selectedBranch]
235
+ );
236
+
237
+ // Handle branch selection
238
+ const toLocalBranchName = useCallback((remoteName: string) => {
239
+ const segments = remoteName.split('/');
240
+ if (segments.length <= 1) {
241
+ return remoteName;
242
+ }
243
+ return segments.slice(1).join('/');
244
+ }, []);
245
+
246
+ const inferBranchCategory = useCallback(
247
+ (branchName: string): BranchInfo['branchType'] => {
248
+ const matched = branches.find((branch) => branch.name === branchName);
249
+ if (matched) {
250
+ return matched.branchType;
251
+ }
252
+ if (branchName === 'main' || branchName === 'master') {
253
+ return 'main';
254
+ }
255
+ if (branchName === 'develop' || branchName === 'dev') {
256
+ return 'develop';
257
+ }
258
+ if (branchName.startsWith('feature/')) {
259
+ return 'feature';
260
+ }
261
+ if (branchName.startsWith('hotfix/')) {
262
+ return 'hotfix';
263
+ }
264
+ if (branchName.startsWith('release/')) {
265
+ return 'release';
266
+ }
267
+ return 'other';
268
+ },
269
+ [branches]
270
+ );
271
+
272
+ const isProtectedSelection = useCallback(
273
+ (branch: SelectedBranchState | null): boolean => {
274
+ if (!branch) {
275
+ return false;
276
+ }
277
+ return (
278
+ isProtectedBranchName(branch.name) ||
279
+ isProtectedBranchName(branch.displayName) ||
280
+ (branch.remoteBranch ? isProtectedBranchName(branch.remoteBranch) : false) ||
281
+ branch.branchCategory === 'main' ||
282
+ branch.branchCategory === 'develop'
283
+ );
284
+ },
285
+ [isProtectedBranchName]
286
+ );
287
+
288
+ const protectedBranchInfo = useMemo(() => {
289
+ if (!selectedBranch) {
290
+ return null;
291
+ }
292
+ if (!isProtectedSelection(selectedBranch)) {
293
+ return null;
294
+ }
295
+ const label = selectedBranch.displayName ?? selectedBranch.name;
296
+ return {
297
+ label,
298
+ message: `${label} is a root branch. Switch within the repository root instead of creating a worktree.`,
299
+ };
300
+ }, [selectedBranch, isProtectedSelection]);
301
+
302
+ const handleSelect = useCallback(
303
+ (item: BranchItem) => {
304
+ const selection: SelectedBranchState =
305
+ item.type === 'remote'
306
+ ? {
307
+ name: toLocalBranchName(item.name),
308
+ displayName: item.name,
309
+ branchType: 'remote',
310
+ branchCategory: item.branchType,
311
+ remoteBranch: item.name,
312
+ }
313
+ : {
314
+ name: item.name,
315
+ displayName: item.name,
316
+ branchType: 'local',
317
+ branchCategory: item.branchType,
318
+ };
319
+
320
+ const protectedSelected = isProtectedSelection(selection);
321
+
322
+ setSelectedBranch(selection);
323
+ setSelectedTool(null);
324
+ setCreationSourceBranch(null);
325
+
326
+ if (protectedSelected) {
327
+ setCleanupFooterMessage({
328
+ text: PROTECTED_BRANCH_WARNING,
329
+ color: 'yellow',
330
+ });
331
+ } else {
332
+ setCleanupFooterMessage(null);
333
+ }
334
+
335
+ navigateTo('branch-action-selector');
336
+ },
337
+ [isProtectedSelection, navigateTo, setCleanupFooterMessage, setCreationSourceBranch, setSelectedTool, toLocalBranchName]
338
+ );
339
+
340
+ // Handle navigation
341
+ const handleNavigate = useCallback(
342
+ (screen: string) => {
343
+ navigateTo(screen as any);
344
+ },
345
+ [navigateTo]
346
+ );
347
+
348
+ const handleWorktreeSelect = useCallback(
349
+ (worktree: WorktreeItem) => {
350
+ setSelectedBranch({
351
+ name: worktree.branch,
352
+ displayName: worktree.branch,
353
+ branchType: 'local',
354
+ branchCategory: inferBranchCategory(worktree.branch),
355
+ });
356
+ setSelectedTool(null);
357
+ setCreationSourceBranch(null);
358
+ setCleanupFooterMessage(null);
359
+ navigateTo('ai-tool-selector');
360
+ },
361
+ [inferBranchCategory, navigateTo, setCleanupFooterMessage, setCreationSourceBranch]
362
+ );
363
+
364
+ // Handle branch action selection
365
+ const handleProtectedBranchSwitch = useCallback(async () => {
366
+ if (!selectedBranch) {
367
+ return;
368
+ }
369
+
370
+ try {
371
+ setCleanupFooterMessage({
372
+ text: `Preparing root branch '${selectedBranch.displayName ?? selectedBranch.name}'...`,
373
+ color: 'cyan',
374
+ });
375
+ const repoRoot = await getRepositoryRoot();
376
+ const remoteRef =
377
+ selectedBranch.remoteBranch ??
378
+ (selectedBranch.branchType === 'remote'
379
+ ? selectedBranch.displayName ?? selectedBranch.name
380
+ : null);
381
+
382
+ const result = await switchToProtectedBranch({
383
+ branchName: selectedBranch.name,
384
+ repoRoot,
385
+ remoteRef: remoteRef ?? null,
386
+ });
387
+
388
+ let successMessage = `'${selectedBranch.displayName ?? selectedBranch.name}' will use the repository root.`;
389
+ if (result === 'remote') {
390
+ successMessage = `Created a local tracking branch for '${selectedBranch.displayName ?? selectedBranch.name}' and switched to the protected branch.`;
391
+ } else if (result === 'local') {
392
+ successMessage = `Checked out '${selectedBranch.displayName ?? selectedBranch.name}' in the repository root.`;
393
+ }
394
+
395
+ setCleanupFooterMessage({
396
+ text: successMessage,
397
+ color: 'green',
398
+ });
399
+ refresh();
400
+ navigateTo('ai-tool-selector');
401
+ } catch (error) {
402
+ const message =
403
+ error instanceof Error ? error.message : String(error);
404
+ setCleanupFooterMessage({
405
+ text: `Failed to switch root branch: ${message}`,
406
+ color: 'red',
407
+ });
408
+ console.error('Failed to switch protected branch:', error);
409
+ }
410
+ }, [
411
+ navigateTo,
412
+ refresh,
413
+ selectedBranch,
414
+ setCleanupFooterMessage,
415
+ ]);
416
+
417
+ const handleUseExistingBranch = useCallback(() => {
418
+ if (selectedBranch && isProtectedSelection(selectedBranch)) {
419
+ void handleProtectedBranchSwitch();
420
+ return;
421
+ }
422
+ navigateTo('ai-tool-selector');
423
+ }, [handleProtectedBranchSwitch, isProtectedSelection, navigateTo, selectedBranch]);
424
+
425
+ const handleCreateNewBranch = useCallback(() => {
426
+ setCreationSourceBranch(selectedBranch);
427
+ navigateTo('branch-creator');
428
+ }, [navigateTo, selectedBranch]);
429
+
430
+ // Handle quit
431
+ const handleQuit = useCallback(() => {
432
+ onExit();
433
+ exit();
434
+ }, [onExit, exit]);
435
+
436
+ // Handle branch creation
437
+ const handleCreate = useCallback(
438
+ async (branchName: string) => {
439
+ try {
440
+ const repoRoot = await getRepositoryRoot();
441
+ const worktreePath = await generateWorktreePath(repoRoot, branchName);
442
+ // Use selectedBranch as base if available, otherwise resolve from repo
443
+ const baseBranch = resolveBaseBranchRef(
444
+ creationSourceBranch,
445
+ selectedBranch,
446
+ resolveBaseBranch,
447
+ );
448
+
449
+ await createWorktree({
450
+ branchName,
451
+ worktreePath,
452
+ repoRoot,
453
+ isNewBranch: true,
454
+ baseBranch,
455
+ });
456
+
457
+ refresh();
458
+ setCreationSourceBranch(null);
459
+ setSelectedBranch({
460
+ name: branchName,
461
+ displayName: branchName,
462
+ branchType: 'local',
463
+ branchCategory: inferBranchCategory(branchName),
464
+ });
465
+ setSelectedTool(null);
466
+ setCleanupFooterMessage(null);
467
+
468
+ navigateTo('ai-tool-selector');
469
+ } catch (error) {
470
+ // On error, go back to branch list
471
+ console.error('Failed to create branch:', error);
472
+ goBack();
473
+ refresh();
474
+ }
475
+ },
476
+ [
477
+ navigateTo,
478
+ goBack,
479
+ refresh,
480
+ resolveBaseBranch,
481
+ selectedBranch,
482
+ creationSourceBranch,
483
+ inferBranchCategory,
484
+ setCleanupFooterMessage,
485
+ ]
486
+ );
487
+
488
+ const handleCleanupCommand = useCallback(async () => {
489
+ if (cleanupInputLocked) {
490
+ return;
491
+ }
492
+
493
+ if (completionTimerRef.current) {
494
+ clearTimeout(completionTimerRef.current);
495
+ completionTimerRef.current = null;
496
+ }
497
+
498
+ const succeededBranches: string[] = [];
499
+
500
+ const resetAfterWait = () => {
501
+ setCleanupIndicators({});
502
+ setCleanupInputLocked(false);
503
+ setCleanupFooterMessage(null);
504
+ if (succeededBranches.length > 0) {
505
+ setHiddenBranches((prev) => {
506
+ const merged = new Set(prev);
507
+ succeededBranches.forEach((branch) => merged.add(branch));
508
+ return Array.from(merged);
509
+ });
510
+ }
511
+ refresh();
512
+ completionTimerRef.current = null;
513
+ };
514
+
515
+ // Provide immediate feedback before fetching targets
516
+ setCleanupInputLocked(true);
517
+ setCleanupIndicators({});
518
+ const initialFrame = getSpinnerFrame(0);
519
+ setCleanupFooterMessage({ text: `Processing... ${initialFrame}`, color: 'cyan' });
520
+ setCleanupProcessingBranch(null);
521
+ spinnerFrameIndexRef.current = 0;
522
+ setSpinnerFrameIndex(0);
523
+
524
+ let targets;
525
+ try {
526
+ targets = await getMergedPRWorktrees();
527
+ } catch (error) {
528
+ const message = error instanceof Error ? error.message : String(error);
529
+ setCleanupIndicators({});
530
+ setCleanupFooterMessage({ text: `❌ ${message}`, color: 'red' });
531
+ setCleanupInputLocked(false);
532
+ completionTimerRef.current = setTimeout(() => {
533
+ setCleanupFooterMessage(null);
534
+ completionTimerRef.current = null;
535
+ }, COMPLETION_HOLD_DURATION_MS);
536
+ return;
537
+ }
538
+
539
+ if (targets.length === 0) {
540
+ setCleanupIndicators({});
541
+ setCleanupFooterMessage({ text: '✅ Nothing to clean up.', color: 'green' });
542
+ setCleanupInputLocked(false);
543
+ completionTimerRef.current = setTimeout(() => {
544
+ setCleanupFooterMessage(null);
545
+ completionTimerRef.current = null;
546
+ }, COMPLETION_HOLD_DURATION_MS);
547
+ return;
548
+ }
549
+
550
+ // Reset hidden branches that may already be gone
551
+ setHiddenBranches((prev) => prev.filter((name) => targets.find((t) => t.branch === name) === undefined));
552
+
553
+ const initialIndicators = targets.reduce<Record<string, { icon: string; color?: 'cyan' | 'green' | 'yellow' | 'red' }>>((acc, target, index) => {
554
+ const icon = index === 0 ? getSpinnerFrame(0) : '⏳';
555
+ const color: 'cyan' | 'green' | 'yellow' | 'red' = index === 0 ? 'cyan' : 'yellow';
556
+ acc[target.branch] = { icon, color };
557
+ return acc;
558
+ }, {});
559
+
560
+ setCleanupIndicators(initialIndicators);
561
+ const firstTarget = targets.length > 0 ? targets[0] : undefined;
562
+ setCleanupProcessingBranch(firstTarget ? firstTarget.branch : null);
563
+ spinnerFrameIndexRef.current = 0;
564
+ setSpinnerFrameIndex(0);
565
+ setCleanupFooterMessage({ text: `Processing... ${getSpinnerFrame(0)}`, color: 'cyan' });
566
+
567
+ for (let index = 0; index < targets.length; index += 1) {
568
+ const currentTarget = targets[index];
569
+ if (!currentTarget) {
570
+ continue;
571
+ }
572
+ const target = currentTarget;
573
+
574
+ setCleanupProcessingBranch(target.branch);
575
+ spinnerFrameIndexRef.current = 0;
576
+ setSpinnerFrameIndex(0);
577
+
578
+ setCleanupIndicators((prev) => {
579
+ const updated = { ...prev };
580
+ updated[target.branch] = { icon: getSpinnerFrame(0), color: 'cyan' };
581
+ for (const pending of targets.slice(index + 1)) {
582
+ const current = updated[pending.branch];
583
+ if (!current || current.icon !== '⏳') {
584
+ updated[pending.branch] = { icon: '⏳', color: 'yellow' };
585
+ }
586
+ }
587
+ return updated;
588
+ });
589
+
590
+ const shouldSkip =
591
+ target.hasUncommittedChanges ||
592
+ target.hasUnpushedCommits ||
593
+ (target.cleanupType === 'worktree-and-branch' && (!target.worktreePath || target.isAccessible === false));
594
+
595
+ if (shouldSkip) {
596
+ setCleanupIndicators((prev) => ({
597
+ ...prev,
598
+ [target.branch]: { icon: '⏭️', color: 'yellow' },
599
+ }));
600
+ setCleanupProcessingBranch(null);
601
+ continue;
602
+ }
603
+
604
+ try {
605
+ if (target.cleanupType === 'worktree-and-branch' && target.worktreePath) {
606
+ await removeWorktree(target.worktreePath, true);
607
+ }
608
+
609
+ await deleteBranch(target.branch, true);
610
+ succeededBranches.push(target.branch);
611
+ setCleanupIndicators((prev) => ({
612
+ ...prev,
613
+ [target.branch]: { icon: '✅', color: 'green' },
614
+ }));
615
+ } catch {
616
+ const icon = '❌';
617
+ setCleanupIndicators((prev) => ({
618
+ ...prev,
619
+ [target.branch]: { icon, color: 'red' },
620
+ }));
621
+ }
622
+
623
+ setCleanupProcessingBranch(null);
624
+ }
625
+
626
+ setCleanupProcessingBranch(null);
627
+ setCleanupInputLocked(false);
628
+ setCleanupFooterMessage({ text: 'Cleanup completed. Finalizing...', color: 'green' });
629
+
630
+ const holdDuration =
631
+ typeof process !== 'undefined' && process.env?.NODE_ENV === 'test'
632
+ ? 0
633
+ : COMPLETION_HOLD_DURATION_MS;
634
+
635
+ completionTimerRef.current = setTimeout(resetAfterWait, holdDuration);
636
+ }, [cleanupInputLocked, deleteBranch, getMergedPRWorktrees, refresh, removeWorktree]);
637
+
638
+ // Handle AI tool selection
639
+ const handleToolSelect = useCallback(
640
+ (tool: AITool) => {
641
+ setSelectedTool(tool);
642
+ navigateTo('execution-mode-selector');
643
+ },
644
+ [navigateTo]
645
+ );
646
+
647
+ // Handle session selection
648
+ const handleSessionSelect = useCallback(
649
+ (_session: string) => {
650
+ // TODO: Load selected session and navigate to next screen
651
+ // For now, just go back to branch list
652
+ goBack();
653
+ },
654
+ [goBack]
655
+ );
656
+
657
+ // Handle execution mode and skipPermissions selection
658
+ const handleModeSelect = useCallback(
659
+ (result: { mode: ExecutionMode; skipPermissions: boolean }) => {
660
+ // All selections complete - exit with result
661
+ if (selectedBranch && selectedTool) {
662
+ const payload: SelectionResult = {
663
+ branch: selectedBranch.name,
664
+ displayName: selectedBranch.displayName,
665
+ branchType: selectedBranch.branchType,
666
+ tool: selectedTool,
667
+ mode: result.mode,
668
+ skipPermissions: result.skipPermissions,
669
+ ...(selectedBranch.remoteBranch
670
+ ? { remoteBranch: selectedBranch.remoteBranch }
671
+ : {}),
672
+ };
673
+
674
+ onExit(payload);
675
+ exit();
676
+ }
677
+ },
678
+ [selectedBranch, selectedTool, onExit, exit]
679
+ );
680
+
681
+ // Render screen based on currentScreen
682
+ const renderScreen = () => {
683
+ switch (currentScreen) {
684
+ case 'branch-list':
685
+ return (
686
+ <BranchListScreen
687
+ branches={branchItems}
688
+ stats={stats}
689
+ onSelect={handleSelect}
690
+ onNavigate={handleNavigate}
691
+ onQuit={handleQuit}
692
+ onCleanupCommand={handleCleanupCommand}
693
+ onRefresh={refresh}
694
+ loading={loading}
695
+ error={error}
696
+ lastUpdated={lastUpdated}
697
+ loadingIndicatorDelay={loadingIndicatorDelay}
698
+ cleanupUI={{
699
+ indicators: cleanupIndicators,
700
+ footerMessage: cleanupFooterMessage,
701
+ inputLocked: cleanupInputLocked,
702
+ }}
703
+ version={version}
704
+ workingDirectory={workingDirectory}
705
+ />
706
+ );
707
+
708
+ case 'worktree-manager':
709
+ return (
710
+ <WorktreeManagerScreen
711
+ worktrees={worktreeItems}
712
+ onBack={goBack}
713
+ onSelect={handleWorktreeSelect}
714
+ version={version}
715
+ />
716
+ );
717
+
718
+ case 'branch-creator':
719
+ return (
720
+ <BranchCreatorScreen
721
+ onBack={goBack}
722
+ onCreate={handleCreate}
723
+ baseBranch={baseBranchLabel}
724
+ version={version}
725
+ />
726
+ );
727
+
728
+ case 'branch-action-selector': {
729
+ const isProtected = Boolean(protectedBranchInfo);
730
+ const baseProps = {
731
+ selectedBranch: selectedBranch?.displayName ?? '',
732
+ onUseExisting: handleUseExistingBranch,
733
+ onCreateNew: handleCreateNewBranch,
734
+ onBack: goBack,
735
+ canCreateNew: Boolean(selectedBranch),
736
+ };
737
+
738
+ if (isProtected) {
739
+ return (
740
+ <BranchActionSelectorScreen
741
+ {...baseProps}
742
+ mode="protected"
743
+ infoMessage={protectedBranchInfo?.message ?? null}
744
+ primaryLabel="Use root branch (no worktree)"
745
+ secondaryLabel="Create new branch from this branch"
746
+ />
747
+ );
748
+ }
749
+
750
+ return <BranchActionSelectorScreen {...baseProps} />;
751
+ }
752
+
753
+ case 'ai-tool-selector':
754
+ return <AIToolSelectorScreen onBack={goBack} onSelect={handleToolSelect} version={version} />;
755
+
756
+ case 'session-selector':
757
+ // TODO: Implement session data fetching
758
+ return (
759
+ <SessionSelectorScreen
760
+ sessions={[]}
761
+ onBack={goBack}
762
+ onSelect={handleSessionSelect}
763
+ version={version}
764
+ />
765
+ );
766
+
767
+ case 'execution-mode-selector':
768
+ return (
769
+ <ExecutionModeSelectorScreen onBack={goBack} onSelect={handleModeSelect} version={version} />
770
+ );
771
+
772
+ default:
773
+ return (
774
+ <BranchListScreen
775
+ branches={branchItems}
776
+ stats={stats}
777
+ onSelect={handleSelect}
778
+ onNavigate={handleNavigate}
779
+ onQuit={handleQuit}
780
+ onRefresh={refresh}
781
+ loading={loading}
782
+ error={error}
783
+ lastUpdated={lastUpdated}
784
+ loadingIndicatorDelay={loadingIndicatorDelay}
785
+ version={version}
786
+ workingDirectory={workingDirectory}
787
+ />
788
+ );
789
+ }
790
+ };
791
+
792
+ return <ErrorBoundary>{renderScreen()}</ErrorBoundary>;
793
+ }