@akiojin/gwt 4.3.0 → 4.4.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 (50) hide show
  1. package/dist/claude.d.ts.map +1 -1
  2. package/dist/claude.js +39 -2
  3. package/dist/claude.js.map +1 -1
  4. package/dist/cli/ui/components/App.d.ts.map +1 -1
  5. package/dist/cli/ui/components/App.js +12 -61
  6. package/dist/cli/ui/components/App.js.map +1 -1
  7. package/dist/cli/ui/components/common/SpinnerIcon.d.ts +20 -0
  8. package/dist/cli/ui/components/common/SpinnerIcon.d.ts.map +1 -0
  9. package/dist/cli/ui/components/common/SpinnerIcon.js +61 -0
  10. package/dist/cli/ui/components/common/SpinnerIcon.js.map +1 -0
  11. package/dist/cli/ui/components/parts/Stats.d.ts +2 -5
  12. package/dist/cli/ui/components/parts/Stats.d.ts.map +1 -1
  13. package/dist/cli/ui/components/parts/Stats.js +16 -3
  14. package/dist/cli/ui/components/parts/Stats.js.map +1 -1
  15. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +6 -2
  16. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  17. package/dist/cli/ui/components/screens/BranchListScreen.js +95 -42
  18. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  19. package/dist/cli/ui/hooks/useAppInput.d.ts +1 -0
  20. package/dist/cli/ui/hooks/useAppInput.d.ts.map +1 -1
  21. package/dist/cli/ui/hooks/useAppInput.js +2 -1
  22. package/dist/cli/ui/hooks/useAppInput.js.map +1 -1
  23. package/dist/cli/ui/hooks/useGitData.d.ts +1 -0
  24. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  25. package/dist/cli/ui/hooks/useGitData.js +43 -15
  26. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  27. package/dist/cli/ui/types.d.ts +4 -0
  28. package/dist/cli/ui/types.d.ts.map +1 -1
  29. package/dist/git.d.ts +2 -0
  30. package/dist/git.d.ts.map +1 -1
  31. package/dist/git.js +38 -14
  32. package/dist/git.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +14 -1
  36. package/dist/index.js.map +1 -1
  37. package/package.json +4 -4
  38. package/src/claude.ts +45 -2
  39. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +208 -0
  40. package/src/cli/ui/__tests__/hooks/useGitData.nonblocking.test.tsx +158 -0
  41. package/src/cli/ui/components/App.tsx +22 -77
  42. package/src/cli/ui/components/common/SpinnerIcon.tsx +86 -0
  43. package/src/cli/ui/components/parts/Stats.tsx +24 -3
  44. package/src/cli/ui/components/screens/BranchListScreen.tsx +117 -45
  45. package/src/cli/ui/hooks/useAppInput.ts +2 -1
  46. package/src/cli/ui/hooks/useGitData.ts +101 -18
  47. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +46 -1
  48. package/src/cli/ui/types.ts +5 -0
  49. package/src/git.ts +48 -17
  50. package/src/index.ts +14 -1
@@ -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(),
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;
package/src/git.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execa } from "execa";
2
2
  import path from "node:path";
3
3
  import { BranchInfo } from "./cli/ui/types.js";
4
+ import { GIT_CONFIG } from "./config/constants.js";
4
5
 
5
6
  export class GitError extends Error {
6
7
  constructor(
@@ -40,12 +41,17 @@ export async function isGitRepository(): Promise<boolean> {
40
41
  console.error(`[DEBUG] git rev-parse --git-dir: ${result.stdout}`);
41
42
  }
42
43
  return true;
43
- } catch (error: any) {
44
+ } catch (error: unknown) {
44
45
  // Debug: log the error for troubleshooting
45
46
  if (process.env.DEBUG) {
46
- console.error(`[DEBUG] git rev-parse --git-dir failed:`, error.message);
47
- if (error.stderr) {
48
- console.error(`[DEBUG] stderr:`, error.stderr);
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ const stderr =
49
+ typeof error === "object" && error !== null && "stderr" in error
50
+ ? (error as { stderr?: string }).stderr
51
+ : undefined;
52
+ console.error(`[DEBUG] git rev-parse --git-dir failed:`, message);
53
+ if (stderr) {
54
+ console.error(`[DEBUG] stderr:`, stderr);
49
55
  }
50
56
  }
51
57
  return false;
@@ -752,15 +758,27 @@ export async function getEnhancedSessionInfo(
752
758
 
753
759
  export async function fetchAllRemotes(options?: {
754
760
  cwd?: string;
761
+ timeoutMs?: number;
762
+ allowPrompt?: boolean;
755
763
  }): Promise<void> {
756
764
  try {
757
- const execOptions = options?.cwd ? { cwd: options.cwd } : undefined;
765
+ const execOptions = options?.cwd ? { cwd: options.cwd } : {};
766
+ const timeoutMs = options?.timeoutMs ?? GIT_CONFIG.FETCH_TIMEOUT;
767
+ const allowPrompt = options?.allowPrompt === true;
768
+ const env = allowPrompt
769
+ ? process.env
770
+ : {
771
+ ...process.env,
772
+ GIT_TERMINAL_PROMPT: "0",
773
+ GCM_INTERACTIVE: "Never",
774
+ };
758
775
  const args = ["fetch", "--all", "--prune"];
759
- if (execOptions) {
760
- await execa("git", args, execOptions);
761
- } else {
762
- await execa("git", args);
763
- }
776
+ await execa("git", args, {
777
+ ...execOptions,
778
+ timeout: timeoutMs,
779
+ env,
780
+ stdin: allowPrompt ? "inherit" : "ignore",
781
+ });
764
782
  } catch (error) {
765
783
  throw new GitError("Failed to fetch remote branches", error);
766
784
  }
@@ -854,11 +872,19 @@ export async function executeNpmVersionInWorktree(
854
872
  { cwd: worktreePath },
855
873
  );
856
874
  }
857
- } catch (error: any) {
875
+ } catch (error: unknown) {
858
876
  // エラーの詳細情報を含める
859
877
  const errorMessage = error instanceof Error ? error.message : String(error);
860
- const errorDetails = error?.stderr ? ` (stderr: ${error.stderr})` : "";
861
- const errorStdout = error?.stdout ? ` (stdout: ${error.stdout})` : "";
878
+ const stderr =
879
+ typeof error === "object" && error !== null && "stderr" in error
880
+ ? (error as { stderr?: string }).stderr
881
+ : undefined;
882
+ const stdout =
883
+ typeof error === "object" && error !== null && "stdout" in error
884
+ ? (error as { stdout?: string }).stdout
885
+ : undefined;
886
+ const errorDetails = stderr ? ` (stderr: ${stderr})` : "";
887
+ const errorStdout = stdout ? ` (stdout: ${stdout})` : "";
862
888
  throw new GitError(
863
889
  `Failed to update version to ${newVersion} in worktree: ${errorMessage}${errorDetails}${errorStdout}`,
864
890
  error,
@@ -1201,9 +1227,13 @@ export async function ensureGitignoreEntry(
1201
1227
  if (content.includes("\r\n")) {
1202
1228
  eol = "\r\n";
1203
1229
  }
1204
- } catch (error: any) {
1230
+ } catch (error: unknown) {
1205
1231
  // ENOENTエラー(ファイルが存在しない)は無視
1206
- if (error.code !== "ENOENT") {
1232
+ const code =
1233
+ typeof error === "object" && error !== null && "code" in error
1234
+ ? (error as { code?: string }).code
1235
+ : undefined;
1236
+ if (code !== "ENOENT") {
1207
1237
  throw error;
1208
1238
  }
1209
1239
  }
@@ -1222,7 +1252,8 @@ export async function ensureGitignoreEntry(
1222
1252
 
1223
1253
  const newContent = `${content}${separator}${entry}${eol}`;
1224
1254
  await fs.writeFile(gitignorePath, newContent, "utf-8");
1225
- } catch (error: any) {
1226
- throw new GitError(`Failed to update .gitignore: ${error.message}`, error);
1255
+ } catch (error: unknown) {
1256
+ const message = error instanceof Error ? error.message : String(error);
1257
+ throw new GitError(`Failed to update .gitignore: ${message}`, error);
1227
1258
  }
1228
1259
  }