@akiojin/gwt 4.3.1 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli/ui/components/App.d.ts.map +1 -1
  2. package/dist/cli/ui/components/App.js +12 -61
  3. package/dist/cli/ui/components/App.js.map +1 -1
  4. package/dist/cli/ui/components/common/SpinnerIcon.d.ts +20 -0
  5. package/dist/cli/ui/components/common/SpinnerIcon.d.ts.map +1 -0
  6. package/dist/cli/ui/components/common/SpinnerIcon.js +61 -0
  7. package/dist/cli/ui/components/common/SpinnerIcon.js.map +1 -0
  8. package/dist/cli/ui/components/parts/Stats.d.ts +2 -5
  9. package/dist/cli/ui/components/parts/Stats.d.ts.map +1 -1
  10. package/dist/cli/ui/components/parts/Stats.js +16 -3
  11. package/dist/cli/ui/components/parts/Stats.js.map +1 -1
  12. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +6 -2
  13. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  14. package/dist/cli/ui/components/screens/BranchListScreen.js +95 -42
  15. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  16. package/dist/cli/ui/hooks/useAppInput.d.ts +1 -0
  17. package/dist/cli/ui/hooks/useAppInput.d.ts.map +1 -1
  18. package/dist/cli/ui/hooks/useAppInput.js +2 -1
  19. package/dist/cli/ui/hooks/useAppInput.js.map +1 -1
  20. package/dist/cli/ui/hooks/useGitData.d.ts +1 -0
  21. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  22. package/dist/cli/ui/hooks/useGitData.js +43 -15
  23. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  24. package/dist/cli/ui/types.d.ts +4 -0
  25. package/dist/cli/ui/types.d.ts.map +1 -1
  26. package/dist/git.d.ts +7 -4
  27. package/dist/git.d.ts.map +1 -1
  28. package/dist/git.js +54 -34
  29. package/dist/git.js.map +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +14 -1
  33. package/dist/index.js.map +1 -1
  34. package/package.json +4 -4
  35. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +208 -0
  36. package/src/cli/ui/__tests__/hooks/useGitData.nonblocking.test.tsx +206 -0
  37. package/src/cli/ui/components/App.tsx +22 -77
  38. package/src/cli/ui/components/common/SpinnerIcon.tsx +86 -0
  39. package/src/cli/ui/components/parts/Stats.tsx +24 -3
  40. package/src/cli/ui/components/screens/BranchListScreen.tsx +117 -45
  41. package/src/cli/ui/hooks/useAppInput.ts +2 -1
  42. package/src/cli/ui/hooks/useGitData.ts +101 -18
  43. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +46 -1
  44. package/src/cli/ui/types.ts +5 -0
  45. package/src/git.ts +72 -37
  46. package/src/index.ts +14 -1
@@ -1,11 +1,12 @@
1
1
  import React from "react";
2
2
  import { Box, Text } from "ink";
3
- import type { Statistics } from "../../types.js";
3
+ import type { Statistics, BranchViewMode } from "../../types.js";
4
4
 
5
5
  export interface StatsProps {
6
6
  stats: Statistics;
7
7
  separator?: string;
8
8
  lastUpdated?: Date | null;
9
+ viewMode?: BranchViewMode;
9
10
  }
10
11
 
11
12
  /**
@@ -30,13 +31,24 @@ function formatRelativeTime(date: Date): string {
30
31
  }
31
32
 
32
33
  /**
33
- * Stats component - displays statistics in one line
34
- * Optimized with React.memo to prevent unnecessary re-renders
34
+ * Format view mode label for display
35
35
  */
36
+ function formatViewModeLabel(mode: BranchViewMode): string {
37
+ switch (mode) {
38
+ case "all":
39
+ return "All";
40
+ case "local":
41
+ return "Local";
42
+ case "remote":
43
+ return "Remote";
44
+ }
45
+ }
46
+
36
47
  export const Stats = React.memo(function Stats({
37
48
  stats,
38
49
  separator = " ",
39
50
  lastUpdated = null,
51
+ viewMode,
40
52
  }: StatsProps) {
41
53
  const items = [
42
54
  { label: "Local", value: stats.localCount, color: "cyan" },
@@ -47,6 +59,15 @@ export const Stats = React.memo(function Stats({
47
59
 
48
60
  return (
49
61
  <Box>
62
+ {viewMode && (
63
+ <Box>
64
+ <Text dimColor>Mode: </Text>
65
+ <Text bold color="white">
66
+ {formatViewModeLabel(viewMode)}
67
+ </Text>
68
+ <Text dimColor>{separator}</Text>
69
+ </Box>
70
+ )}
50
71
  {items.map((item) => (
51
72
  <Box key={item.label}>
52
73
  <Text dimColor>{item.label}: </Text>
@@ -6,9 +6,10 @@ import { Footer } from "../parts/Footer.js";
6
6
  import { Select } from "../common/Select.js";
7
7
  import { Input } from "../common/Input.js";
8
8
  import { LoadingIndicator } from "../common/LoadingIndicator.js";
9
+ import { useSpinnerFrame } from "../common/SpinnerIcon.js";
9
10
  import { useAppInput } from "../../hooks/useAppInput.js";
10
11
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
11
- import type { BranchItem, Statistics } from "../../types.js";
12
+ import type { BranchItem, Statistics, BranchViewMode } from "../../types.js";
12
13
  import stringWidth from "string-width";
13
14
  import stripAnsi from "strip-ansi";
14
15
  import chalk from "chalk";
@@ -64,11 +65,13 @@ type IndicatorColor = "cyan" | "green" | "yellow" | "red";
64
65
 
65
66
  interface CleanupIndicator {
66
67
  icon: string;
68
+ isSpinning?: boolean;
67
69
  color?: IndicatorColor;
68
70
  }
69
71
 
70
72
  interface CleanupFooterMessage {
71
73
  text: string;
74
+ isSpinning?: boolean;
72
75
  color?: IndicatorColor;
73
76
  }
74
77
 
@@ -102,6 +105,8 @@ export interface BranchListScreenProps {
102
105
  testOnFilterModeChange?: (mode: boolean) => void;
103
106
  testFilterQuery?: string;
104
107
  testOnFilterQueryChange?: (query: string) => void;
108
+ testViewMode?: BranchViewMode;
109
+ testOnViewModeChange?: (mode: BranchViewMode) => void;
105
110
  selectedBranches?: string[];
106
111
  onToggleSelect?: (branchName: string) => void;
107
112
  }
@@ -129,16 +134,31 @@ export function BranchListScreen({
129
134
  testOnFilterModeChange,
130
135
  testFilterQuery,
131
136
  testOnFilterQueryChange,
137
+ testViewMode,
138
+ testOnViewModeChange,
132
139
  selectedBranches = [],
133
140
  onToggleSelect,
134
141
  }: BranchListScreenProps) {
135
142
  const { rows } = useTerminalSize();
136
- const headerText = " Legend: [ ]/[ * ] select 🟢/🔴/⚪ worktree 🛡/⚠ safe";
137
143
  const selectedSet = useMemo(
138
144
  () => new Set(selectedBranches),
139
145
  [selectedBranches],
140
146
  );
141
147
 
148
+ // Check if any indicator needs spinner animation
149
+ const hasSpinningIndicator = useMemo(() => {
150
+ if (!cleanupUI?.indicators) return false;
151
+ return Object.values(cleanupUI.indicators).some((ind) => ind.isSpinning);
152
+ }, [cleanupUI?.indicators]);
153
+
154
+ // Also check footer message for spinner
155
+ const hasSpinningFooter = cleanupUI?.footerMessage?.isSpinning ?? false;
156
+
157
+ // Get spinner frame for all spinning elements
158
+ const spinnerFrame = useSpinnerFrame(
159
+ hasSpinningIndicator || hasSpinningFooter,
160
+ );
161
+
142
162
  // Filter state - allow test control via props
143
163
  const [internalFilterQuery, setInternalFilterQuery] = useState("");
144
164
  const filterQuery =
@@ -164,6 +184,29 @@ export function BranchListScreen({
164
184
  [testOnFilterModeChange],
165
185
  );
166
186
 
187
+ // View mode state for filtering by local/remote
188
+ const [internalViewMode, setInternalViewMode] =
189
+ useState<BranchViewMode>("all");
190
+ const viewMode = testViewMode !== undefined ? testViewMode : internalViewMode;
191
+ const setViewMode = useCallback(
192
+ (mode: BranchViewMode) => {
193
+ setInternalViewMode(mode);
194
+ testOnViewModeChange?.(mode);
195
+ },
196
+ [testOnViewModeChange],
197
+ );
198
+
199
+ // Cycle view mode: all → local → remote → all
200
+ const cycleViewMode = useCallback(() => {
201
+ const modes: BranchViewMode[] = ["all", "local", "remote"];
202
+ const currentIndex = modes.indexOf(viewMode);
203
+ const nextIndex = (currentIndex + 1) % modes.length;
204
+ const nextMode = modes[nextIndex];
205
+ if (nextMode !== undefined) {
206
+ setViewMode(nextMode);
207
+ }
208
+ }, [viewMode, setViewMode]);
209
+
167
210
  // Cursor position for Select (controlled to enable space toggle)
168
211
  const [selectedIndex, setSelectedIndex] = useState(0);
169
212
 
@@ -204,6 +247,13 @@ export function BranchListScreen({
204
247
  return;
205
248
  }
206
249
 
250
+ // Tab key to cycle view mode (only in branch selection mode)
251
+ if (key.tab && !filterMode) {
252
+ cycleViewMode();
253
+ setSelectedIndex(0); // Reset cursor position on mode change
254
+ return;
255
+ }
256
+
207
257
  // Disable global shortcuts while in filter mode
208
258
  if (filterMode) {
209
259
  return;
@@ -219,27 +269,35 @@ export function BranchListScreen({
219
269
  }
220
270
  });
221
271
 
222
- // Filter branches based on query
272
+ // Filter branches based on view mode and query
223
273
  const filteredBranches = useMemo(() => {
224
- if (!filterQuery.trim()) {
225
- return branches;
274
+ let result = branches;
275
+
276
+ // Apply view mode filter
277
+ if (viewMode !== "all") {
278
+ result = result.filter((branch) => branch.type === viewMode);
226
279
  }
227
280
 
228
- const query = filterQuery.toLowerCase();
229
- return branches.filter((branch) => {
230
- // Search in branch name
231
- if (branch.name.toLowerCase().includes(query)) {
232
- return true;
233
- }
281
+ // Apply search filter
282
+ if (filterQuery.trim()) {
283
+ const query = filterQuery.toLowerCase();
284
+ result = result.filter((branch) => {
285
+ // Search in branch name
286
+ if (branch.name.toLowerCase().includes(query)) {
287
+ return true;
288
+ }
234
289
 
235
- // Search in PR title if available (only openPR has title)
236
- if (branch.openPR?.title?.toLowerCase().includes(query)) {
237
- return true;
238
- }
290
+ // Search in PR title if available (only openPR has title)
291
+ if (branch.openPR?.title?.toLowerCase().includes(query)) {
292
+ return true;
293
+ }
239
294
 
240
- return false;
241
- });
242
- }, [branches, filterQuery]);
295
+ return false;
296
+ });
297
+ }
298
+
299
+ return result;
300
+ }, [branches, viewMode, filterQuery]);
243
301
 
244
302
  useEffect(() => {
245
303
  setSelectedIndex((prev) => {
@@ -271,6 +329,7 @@ export function BranchListScreen({
271
329
  const footerActions = [
272
330
  { key: "enter", description: "Select" },
273
331
  { key: "f", description: "Filter" },
332
+ { key: "tab", description: "Mode" },
274
333
  { key: "r", description: "Refresh" },
275
334
  { key: "c", description: "Cleanup" },
276
335
  { key: "p", description: "Profiles" },
@@ -348,7 +407,6 @@ export function BranchListScreen({
348
407
  const columns = Math.max(20, context.columns - 1);
349
408
  const visibleWidth = (value: string) =>
350
409
  measureDisplayWidth(stripAnsi(value));
351
- const arrow = isSelected ? ">" : " ";
352
410
  let commitText = "---";
353
411
  if (item.latestCommitTimestamp) {
354
412
  commitText = formatLatestCommit(item.latestCommitTimestamp);
@@ -384,27 +442,38 @@ export function BranchListScreen({
384
442
  )} | ${paddedDate}`;
385
443
  const timestampWidth = measureDisplayWidth(timestampText);
386
444
 
445
+ // Determine the leading indicator (cursor or cleanup status)
387
446
  const indicatorInfo = cleanupUI?.indicators?.[item.name];
388
- let indicatorIcon = indicatorInfo?.icon ?? "";
389
- if (indicatorIcon && indicatorInfo?.color && !isSelected) {
390
- switch (indicatorInfo.color) {
391
- case "cyan":
392
- indicatorIcon = chalk.cyan(indicatorIcon);
393
- break;
394
- case "green":
395
- indicatorIcon = chalk.green(indicatorIcon);
396
- break;
397
- case "yellow":
398
- indicatorIcon = chalk.yellow(indicatorIcon);
399
- break;
400
- case "red":
401
- indicatorIcon = chalk.red(indicatorIcon);
402
- break;
403
- default:
404
- break;
447
+ let leadingIndicator: string;
448
+ if (indicatorInfo) {
449
+ // Use spinner frame if isSpinning, otherwise use static icon
450
+ let indicatorIcon =
451
+ indicatorInfo.isSpinning && spinnerFrame
452
+ ? spinnerFrame
453
+ : indicatorInfo.icon;
454
+ if (indicatorIcon && indicatorInfo.color && !isSelected) {
455
+ switch (indicatorInfo.color) {
456
+ case "cyan":
457
+ indicatorIcon = chalk.cyan(indicatorIcon);
458
+ break;
459
+ case "green":
460
+ indicatorIcon = chalk.green(indicatorIcon);
461
+ break;
462
+ case "yellow":
463
+ indicatorIcon = chalk.yellow(indicatorIcon);
464
+ break;
465
+ case "red":
466
+ indicatorIcon = chalk.red(indicatorIcon);
467
+ break;
468
+ default:
469
+ break;
470
+ }
405
471
  }
472
+ leadingIndicator = indicatorIcon;
473
+ } else {
474
+ // Normal cursor
475
+ leadingIndicator = isSelected ? ">" : " ";
406
476
  }
407
- const indicatorPrefix = indicatorIcon ? `${indicatorIcon} ` : "";
408
477
 
409
478
  const isChecked = selectedSet.has(item.name);
410
479
  const isWarning = Boolean(item.hasUnpushedCommits) || !item.mergedPR;
@@ -423,7 +492,7 @@ export function BranchListScreen({
423
492
  item.safeToCleanup === true ? chalk.green("🛡") : chalk.yellow("⚠");
424
493
  const stateCluster = `${selectionIcon} ${worktreeIcon} ${safeIcon}`;
425
494
 
426
- const staticPrefix = `${arrow} ${indicatorPrefix}${stateCluster} `;
495
+ const staticPrefix = `${leadingIndicator} ${stateCluster} `;
427
496
  const staticPrefixWidth = visibleWidth(staticPrefix);
428
497
  const maxLeftDisplayWidth = Math.max(0, columns - timestampWidth - 1);
429
498
  const maxLabelWidth = Math.max(
@@ -519,6 +588,7 @@ export function BranchListScreen({
519
588
  truncateToWidth,
520
589
  selectedSet,
521
590
  colorToolLabel,
591
+ spinnerFrame,
522
592
  ],
523
593
  );
524
594
 
@@ -557,7 +627,7 @@ export function BranchListScreen({
557
627
 
558
628
  {/* Stats */}
559
629
  <Box>
560
- <Stats stats={stats} lastUpdated={lastUpdated} />
630
+ <Stats stats={stats} lastUpdated={lastUpdated} viewMode={viewMode} />
561
631
  </Box>
562
632
 
563
633
  {/* Content */}
@@ -602,10 +672,6 @@ export function BranchListScreen({
602
672
  branches.length > 0 &&
603
673
  filteredBranches.length > 0 && (
604
674
  <>
605
- {/* Column labels */}
606
- <Box>
607
- <Text dimColor>{headerText}</Text>
608
- </Box>
609
675
  <Select
610
676
  items={filteredBranches}
611
677
  onSelect={onSelect}
@@ -624,10 +690,16 @@ export function BranchListScreen({
624
690
  <Box marginBottom={1}>
625
691
  {cleanupUI.footerMessage.color ? (
626
692
  <Text color={cleanupUI.footerMessage.color}>
627
- {cleanupUI.footerMessage.text}
693
+ {cleanupUI.footerMessage.isSpinning && spinnerFrame
694
+ ? `${spinnerFrame} ${cleanupUI.footerMessage.text}`
695
+ : cleanupUI.footerMessage.text}
628
696
  </Text>
629
697
  ) : (
630
- <Text>{cleanupUI.footerMessage.text}</Text>
698
+ <Text>
699
+ {cleanupUI.footerMessage.isSpinning && spinnerFrame
700
+ ? `${spinnerFrame} ${cleanupUI.footerMessage.text}`
701
+ : cleanupUI.footerMessage.text}
702
+ </Text>
631
703
  )}
632
704
  </Box>
633
705
  )}
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import { useInput, type Key } from "ink";
3
3
 
4
- const ESCAPE_SEQUENCE_TIMEOUT_MS = 25;
4
+ // WSL/Windows can emit split escape sequences with higher latency.
5
+ export const ESCAPE_SEQUENCE_TIMEOUT_MS = 80;
5
6
 
6
7
  type InputHandler = (input: string, key: Key) => void;
7
8
  type Options = { isActive?: boolean };
@@ -7,6 +7,7 @@ import {
7
7
  collectUpstreamMap,
8
8
  getBranchDivergenceStatuses,
9
9
  } from "../../../git.js";
10
+ import { GIT_CONFIG } from "../../../config/constants.js";
10
11
  import { listAdditionalWorktrees } from "../../../worktree.js";
11
12
  import { getPullRequestByBranch } from "../../../github.js";
12
13
  import type { BranchInfo, WorktreeInfo } from "../types.js";
@@ -28,6 +29,45 @@ export interface UseGitDataResult {
28
29
  lastUpdated: Date | null;
29
30
  }
30
31
 
32
+ export const GIT_DATA_TIMEOUT_MS = 3000;
33
+ const PER_BRANCH_TIMEOUT_MS = 1000;
34
+
35
+ async function withTimeout<T>(
36
+ label: string,
37
+ promise: Promise<T>,
38
+ timeoutMs: number,
39
+ fallback: T,
40
+ ): Promise<T> {
41
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
42
+ let timedOut = false;
43
+
44
+ const timeoutPromise = new Promise<T>((resolve) => {
45
+ timeoutId = setTimeout(() => {
46
+ timedOut = true;
47
+ resolve(fallback);
48
+ }, timeoutMs);
49
+ });
50
+
51
+ const guarded = promise.catch((error) => {
52
+ if (process.env.DEBUG) {
53
+ console.warn(`Failed to resolve ${label}`, error);
54
+ }
55
+ return fallback;
56
+ });
57
+
58
+ const result = await Promise.race([guarded, timeoutPromise]);
59
+
60
+ if (timedOut && process.env.DEBUG) {
61
+ console.warn(`Timed out waiting for ${label}`);
62
+ }
63
+
64
+ if (timeoutId) {
65
+ clearTimeout(timeoutId);
66
+ }
67
+
68
+ return result;
69
+ }
70
+
31
71
  /**
32
72
  * Hook to fetch and manage Git data (branches and worktrees)
33
73
  * @param options - Configuration options for auto-refresh and polling interval
@@ -45,21 +85,37 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
45
85
  setError(null);
46
86
 
47
87
  try {
48
- const repoRoot = await getRepositoryRoot();
88
+ const repoRoot = await withTimeout(
89
+ "repository root",
90
+ getRepositoryRoot(),
91
+ GIT_DATA_TIMEOUT_MS,
92
+ process.cwd(),
93
+ );
49
94
 
50
95
  // リモートブランチの最新情報を取得(失敗してもローカル表示は継続)
51
- try {
52
- await fetchAllRemotes({ cwd: repoRoot });
53
- } catch (fetchError) {
96
+ void fetchAllRemotes({
97
+ cwd: repoRoot,
98
+ timeoutMs: GIT_CONFIG.FETCH_TIMEOUT,
99
+ }).catch((fetchError) => {
54
100
  if (process.env.DEBUG) {
55
101
  console.warn("Failed to fetch remote branches", fetchError);
56
102
  }
57
- }
103
+ });
58
104
 
59
- const branchesData = await getAllBranches();
105
+ const branchesData = await withTimeout(
106
+ "branches",
107
+ getAllBranches(repoRoot),
108
+ GIT_DATA_TIMEOUT_MS,
109
+ [],
110
+ );
60
111
  let worktreesData: GitWorktreeInfo[] = [];
61
112
  try {
62
- worktreesData = await listAdditionalWorktrees();
113
+ worktreesData = await withTimeout(
114
+ "worktrees",
115
+ listAdditionalWorktrees(),
116
+ GIT_DATA_TIMEOUT_MS,
117
+ [],
118
+ );
63
119
  } catch (err) {
64
120
  if (process.env.DEBUG) {
65
121
  console.error("Failed to list additional worktrees:", err);
@@ -74,7 +130,12 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
74
130
  return wt;
75
131
  }
76
132
  try {
77
- const hasUncommitted = await hasUncommittedChanges(wt.path);
133
+ const hasUncommitted = await withTimeout(
134
+ "worktree status",
135
+ hasUncommittedChanges(wt.path),
136
+ PER_BRANCH_TIMEOUT_MS,
137
+ false,
138
+ );
78
139
  return { ...wt, hasUncommittedChanges: hasUncommitted };
79
140
  } catch {
80
141
  return wt;
@@ -82,12 +143,27 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
82
143
  }),
83
144
  );
84
145
 
85
- const lastToolUsageMap = await getLastToolUsageMap(repoRoot);
146
+ const lastToolUsageMap = await withTimeout(
147
+ "last tool usage",
148
+ getLastToolUsageMap(repoRoot),
149
+ GIT_DATA_TIMEOUT_MS,
150
+ new Map(),
151
+ );
86
152
 
87
153
  // upstream情報とdivergence情報を取得
88
154
  const [upstreamMap, divergenceStatuses] = await Promise.all([
89
- collectUpstreamMap(repoRoot),
90
- getBranchDivergenceStatuses({ cwd: repoRoot }).catch(() => []),
155
+ withTimeout(
156
+ "upstream map",
157
+ collectUpstreamMap(repoRoot),
158
+ GIT_DATA_TIMEOUT_MS,
159
+ new Map<string, string>(),
160
+ ),
161
+ withTimeout(
162
+ "divergence",
163
+ getBranchDivergenceStatuses({ cwd: repoRoot }).catch(() => []),
164
+ GIT_DATA_TIMEOUT_MS,
165
+ [],
166
+ ),
91
167
  ]);
92
168
 
93
169
  // divergenceをMapに変換
@@ -153,13 +229,20 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
153
229
  if (branch.type === "local") {
154
230
  try {
155
231
  // Check for unpushed commits
156
- hasUnpushed = await hasUnpushedCommitsInRepo(
157
- branch.name,
158
- repoRoot,
159
- );
160
-
161
- // Check for PR status
162
- prInfo = await getPullRequestByBranch(branch.name);
232
+ [hasUnpushed, prInfo] = await Promise.all([
233
+ withTimeout(
234
+ "unpushed commits",
235
+ hasUnpushedCommitsInRepo(branch.name, repoRoot),
236
+ PER_BRANCH_TIMEOUT_MS,
237
+ false,
238
+ ),
239
+ withTimeout(
240
+ "pull request",
241
+ getPullRequestByBranch(branch.name),
242
+ PER_BRANCH_TIMEOUT_MS,
243
+ null,
244
+ ),
245
+ ]);
163
246
  } catch (error) {
164
247
  // Silently ignore errors to avoid breaking the UI
165
248
  if (process.env.DEBUG) {
@@ -6,6 +6,7 @@ import { act, render } from "@testing-library/react";
6
6
  import { render as inkRender } from "ink-testing-library";
7
7
  import React from "react";
8
8
  import { BranchActionSelectorScreen } from "../BranchActionSelectorScreen.js";
9
+ import { ESCAPE_SEQUENCE_TIMEOUT_MS } from "../../hooks/useAppInput.js";
9
10
  import { Window } from "happy-dom";
10
11
 
11
12
  describe("BranchActionSelectorScreen", () => {
@@ -182,6 +183,50 @@ describe("BranchActionSelectorScreen", () => {
182
183
  inkApp.unmount();
183
184
  });
184
185
 
186
+ it("should treat delayed split down-arrow sequence as navigation (WSL2) and not as Escape", () => {
187
+ vi.useFakeTimers();
188
+ let inkApp: ReturnType<typeof inkRender> | undefined;
189
+
190
+ try {
191
+ const onUseExisting = vi.fn();
192
+ const onCreateNew = vi.fn();
193
+ const onBack = vi.fn();
194
+
195
+ inkApp = inkRender(
196
+ <BranchActionSelectorScreen
197
+ selectedBranch="feature-test"
198
+ onUseExisting={onUseExisting}
199
+ onCreateNew={onCreateNew}
200
+ onBack={onBack}
201
+ />,
202
+ );
203
+
204
+ act(() => {
205
+ inkApp.stdin.write("\u001b");
206
+ });
207
+
208
+ act(() => {
209
+ vi.advanceTimersByTime(ESCAPE_SEQUENCE_TIMEOUT_MS - 10);
210
+ });
211
+
212
+ act(() => {
213
+ inkApp.stdin.write("[");
214
+ inkApp.stdin.write("B");
215
+ });
216
+
217
+ act(() => {
218
+ inkApp.stdin.write("\r");
219
+ });
220
+
221
+ expect(onBack).not.toHaveBeenCalled();
222
+ expect(onCreateNew).toHaveBeenCalledTimes(1);
223
+ expect(onUseExisting).not.toHaveBeenCalled();
224
+ } finally {
225
+ inkApp?.unmount();
226
+ vi.useRealTimers();
227
+ }
228
+ });
229
+
185
230
  it("should still handle Escape key as back navigation", () => {
186
231
  vi.useFakeTimers();
187
232
  let inkApp: ReturnType<typeof inkRender> | undefined;
@@ -205,7 +250,7 @@ describe("BranchActionSelectorScreen", () => {
205
250
  });
206
251
 
207
252
  act(() => {
208
- vi.advanceTimersByTime(25);
253
+ vi.advanceTimersByTime(ESCAPE_SEQUENCE_TIMEOUT_MS);
209
254
  });
210
255
 
211
256
  expect(onBack).toHaveBeenCalledTimes(1);
@@ -12,6 +12,11 @@ export interface WorktreeInfo {
12
12
  export type AITool = string;
13
13
  export type InferenceLevel = "low" | "medium" | "high" | "xhigh";
14
14
 
15
+ /**
16
+ * Branch view mode for filtering branch list by type
17
+ */
18
+ export type BranchViewMode = "all" | "local" | "remote";
19
+
15
20
  export interface ModelOption {
16
21
  id: string;
17
22
  label: string;