@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,40 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { Select, type SelectItem } from './Select.js';
4
+
5
+ export interface ConfirmProps {
6
+ message: string;
7
+ onConfirm: (confirmed: boolean) => void;
8
+ yesLabel?: string;
9
+ noLabel?: string;
10
+ defaultNo?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Confirm component - Yes/No confirmation dialog
15
+ */
16
+ export function Confirm({
17
+ message,
18
+ onConfirm,
19
+ yesLabel = 'Yes',
20
+ noLabel = 'No',
21
+ defaultNo = false,
22
+ }: ConfirmProps) {
23
+ const items: SelectItem[] = [
24
+ { label: yesLabel, value: 'yes' },
25
+ { label: noLabel, value: 'no' },
26
+ ];
27
+
28
+ const handleSelect = (item: SelectItem) => {
29
+ onConfirm(item.value === 'yes');
30
+ };
31
+
32
+ return (
33
+ <Box flexDirection="column">
34
+ <Box marginBottom={1}>
35
+ <Text>{message}</Text>
36
+ </Box>
37
+ <Select items={items} onSelect={handleSelect} initialIndex={defaultNo ? 1 : 0} />
38
+ </Box>
39
+ );
40
+ }
@@ -0,0 +1,57 @@
1
+ import React, { Component, type ReactNode } from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface ErrorBoundaryProps {
5
+ children: ReactNode;
6
+ fallback?: React.ComponentType<{ error: Error }>;
7
+ }
8
+
9
+ interface ErrorBoundaryState {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ }
13
+
14
+ /**
15
+ * Error Boundary component to catch and display errors
16
+ */
17
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
18
+ constructor(props: ErrorBoundaryProps) {
19
+ super(props);
20
+ this.state = { hasError: false, error: null };
21
+ }
22
+
23
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
24
+ return { hasError: true, error };
25
+ }
26
+
27
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
28
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
29
+ }
30
+
31
+ componentDidUpdate(prevProps: ErrorBoundaryProps): void {
32
+ // Reset error state when children change
33
+ if (this.state.hasError && prevProps.children !== this.props.children) {
34
+ this.setState({ hasError: false, error: null });
35
+ }
36
+ }
37
+
38
+ render(): ReactNode {
39
+ if (this.state.hasError && this.state.error) {
40
+ const { fallback: Fallback } = this.props;
41
+
42
+ if (Fallback) {
43
+ return <Fallback error={this.state.error} />;
44
+ }
45
+
46
+ return (
47
+ <Box flexDirection="column" padding={1}>
48
+ <Text color="red" bold>
49
+ Error: {this.state.error.message || 'Unknown error'}
50
+ </Text>
51
+ </Box>
52
+ );
53
+ }
54
+
55
+ return this.props.children;
56
+ }
57
+ }
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+
5
+ export interface InputProps {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ onSubmit: (value: string) => void;
9
+ placeholder?: string;
10
+ label?: string;
11
+ mask?: string;
12
+ }
13
+
14
+ /**
15
+ * Input component - wrapper around ink-text-input with optional label
16
+ */
17
+ export function Input({ value, onChange, onSubmit, placeholder, label, mask }: InputProps) {
18
+ return (
19
+ <Box flexDirection="column">
20
+ {label && (
21
+ <Box marginBottom={0}>
22
+ <Text>{label}</Text>
23
+ </Box>
24
+ )}
25
+ <Box>
26
+ <TextInput
27
+ value={value}
28
+ onChange={onChange}
29
+ onSubmit={onSubmit}
30
+ {...(placeholder !== undefined && { placeholder })}
31
+ {...(mask !== undefined && { mask })}
32
+ />
33
+ </Box>
34
+ </Box>
35
+ );
36
+ }
@@ -0,0 +1,95 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ export interface LoadingIndicatorProps {
5
+ /** true にするとローディング表示を開始する */
6
+ isLoading: boolean;
7
+ /** 表示までの遅延時間 (ms)。デフォルトは 300ms */
8
+ delay?: number;
9
+ /** 表示するメッセージ */
10
+ message?: string;
11
+ /** スピナーの更新間隔 (ms)。デフォルトは 80ms */
12
+ interval?: number;
13
+ /** 使用するスピナーフレーム。ASCII のみを想定 */
14
+ frames?: string[];
15
+ }
16
+
17
+ const DEFAULT_FRAMES = ['|', '/', '-', '\\'];
18
+
19
+ /**
20
+ * ローディング中に簡易スピナーとメッセージを表示するコンポーネント。
21
+ * delay で指定した時間を超えるまでスピナーを表示しないことで、短時間の処理ではちらつきを抑える。
22
+ */
23
+ export function LoadingIndicator({
24
+ isLoading,
25
+ delay = 300,
26
+ message = 'Loading... please wait',
27
+ interval = 80,
28
+ frames = DEFAULT_FRAMES,
29
+ }: LoadingIndicatorProps) {
30
+ const [visible, setVisible] = useState(false);
31
+ const [frameIndex, setFrameIndex] = useState(0);
32
+ const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
33
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
34
+
35
+ // スピナーに使用するフレームをキャッシュ
36
+ const safeFrames = useMemo(() => (frames.length > 0 ? frames : DEFAULT_FRAMES), [frames]);
37
+
38
+ useEffect(() => {
39
+ // ローディングが開始したら、delay後に表示を有効化
40
+ if (isLoading) {
41
+ delayTimerRef.current = setTimeout(() => {
42
+ setVisible(true);
43
+ }, delay);
44
+ } else {
45
+ setVisible(false);
46
+ setFrameIndex(0);
47
+ }
48
+
49
+ return () => {
50
+ if (delayTimerRef.current) {
51
+ clearTimeout(delayTimerRef.current);
52
+ delayTimerRef.current = null;
53
+ }
54
+ };
55
+ }, [isLoading, delay]);
56
+
57
+ useEffect(() => {
58
+ // 表示中のみスピナーを回転
59
+ if (visible && isLoading) {
60
+ intervalRef.current = setInterval(() => {
61
+ setFrameIndex((current) => (current + 1) % safeFrames.length);
62
+ }, interval);
63
+ }
64
+
65
+ return () => {
66
+ if (intervalRef.current) {
67
+ clearInterval(intervalRef.current);
68
+ intervalRef.current = null;
69
+ }
70
+ };
71
+ }, [visible, isLoading, interval, safeFrames.length]);
72
+
73
+ // ローディングが解消されたらタイマーをクリア
74
+ useEffect(() => {
75
+ if (!isLoading && delayTimerRef.current) {
76
+ clearTimeout(delayTimerRef.current);
77
+ delayTimerRef.current = null;
78
+ }
79
+ }, [isLoading]);
80
+
81
+ if (!isLoading || !visible) {
82
+ return null;
83
+ }
84
+
85
+ return (
86
+ <Box gap={1}>
87
+ <Text color="yellow" data-testid="loading-indicator-frame">
88
+ {safeFrames[frameIndex]}
89
+ </Text>
90
+ <Text color="yellow" data-testid="loading-indicator-message">
91
+ {message}
92
+ </Text>
93
+ </Box>
94
+ );
95
+ }
@@ -0,0 +1,216 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text, useInput, useStdout } from 'ink';
3
+
4
+ export interface SelectItem {
5
+ label: string;
6
+ value: string;
7
+ }
8
+
9
+ export interface SelectProps<T extends SelectItem = SelectItem> {
10
+ items: T[];
11
+ onSelect: (item: T) => void;
12
+ limit?: number;
13
+ initialIndex?: number;
14
+ disabled?: boolean;
15
+ renderIndicator?: (item: T, isSelected: boolean) => React.ReactNode;
16
+ renderItem?: (
17
+ item: T,
18
+ isSelected: boolean,
19
+ context: { columns: number }
20
+ ) => React.ReactNode;
21
+ // Optional controlled component props for cursor position
22
+ selectedIndex?: number;
23
+ onSelectedIndexChange?: (index: number) => void;
24
+ }
25
+
26
+ /**
27
+ * Custom comparison function for React.memo
28
+ * Compares items array by content (value and label) instead of reference
29
+ */
30
+ function arePropsEqual<T extends SelectItem = SelectItem>(
31
+ prevProps: SelectProps<T>,
32
+ nextProps: SelectProps<T>
33
+ ): boolean {
34
+ // Check if non-array props are the same
35
+ if (
36
+ prevProps.limit !== nextProps.limit ||
37
+ prevProps.disabled !== nextProps.disabled ||
38
+ prevProps.initialIndex !== nextProps.initialIndex ||
39
+ prevProps.selectedIndex !== nextProps.selectedIndex ||
40
+ prevProps.onSelect !== nextProps.onSelect ||
41
+ prevProps.onSelectedIndexChange !== nextProps.onSelectedIndexChange ||
42
+ prevProps.renderIndicator !== nextProps.renderIndicator ||
43
+ prevProps.renderItem !== nextProps.renderItem
44
+ ) {
45
+ return false;
46
+ }
47
+
48
+ // Check if items arrays have the same length
49
+ if (prevProps.items.length !== nextProps.items.length) {
50
+ return false;
51
+ }
52
+
53
+ // Compare items by content (value and label)
54
+ for (let i = 0; i < prevProps.items.length; i++) {
55
+ const prevItem = prevProps.items[i];
56
+ const nextItem = nextProps.items[i];
57
+
58
+ if (!prevItem || !nextItem) {
59
+ return false;
60
+ }
61
+
62
+ if (prevItem.value !== nextItem.value || prevItem.label !== nextItem.label) {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ // All props are equal
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Select component - custom implementation with no looping
73
+ * Cursor stops at top and bottom instead of wrapping around
74
+ * Wrapped with React.memo for performance optimization
75
+ */
76
+ const SelectComponent = <T extends SelectItem = SelectItem,>({
77
+ items,
78
+ onSelect,
79
+ limit,
80
+ initialIndex = 0,
81
+ disabled = false,
82
+ renderIndicator,
83
+ renderItem,
84
+ selectedIndex: externalSelectedIndex,
85
+ onSelectedIndexChange,
86
+ }: SelectProps<T>) => {
87
+ // Support both controlled and uncontrolled modes
88
+ const [internalSelectedIndex, setInternalSelectedIndex] = useState(initialIndex);
89
+ const [offset, setOffset] = useState(0);
90
+
91
+ // Use external selectedIndex if provided (controlled mode), otherwise use internal state
92
+ const isControlled = externalSelectedIndex !== undefined;
93
+ const selectedIndex = isControlled ? externalSelectedIndex : internalSelectedIndex;
94
+
95
+ const updateSelectedIndex = (value: number | ((prev: number) => number)) => {
96
+ const newIndex = typeof value === 'function' ? value(selectedIndex) : value;
97
+
98
+ if (!isControlled) {
99
+ setInternalSelectedIndex(newIndex);
100
+ }
101
+
102
+ if (onSelectedIndexChange) {
103
+ onSelectedIndexChange(newIndex);
104
+ }
105
+ };
106
+
107
+ useEffect(() => {
108
+ if (items.length === 0) {
109
+ updateSelectedIndex(0);
110
+ setOffset(0);
111
+ return;
112
+ }
113
+
114
+ updateSelectedIndex((current) => {
115
+ const clamped = Math.min(current, items.length - 1);
116
+ return clamped < 0 ? 0 : clamped;
117
+ });
118
+
119
+ if (limit) {
120
+ setOffset((current) => {
121
+ if (current <= items.length - limit) {
122
+ return current < 0 ? 0 : current;
123
+ }
124
+ const newOffset = Math.max(0, items.length - limit);
125
+ return newOffset;
126
+ });
127
+ }
128
+ }, [items, limit]);
129
+
130
+ useInput((input, key) => {
131
+ if (disabled) {
132
+ return;
133
+ }
134
+
135
+ // Only handle navigation and selection keys
136
+ // Let other keys (q, m, n, c, etc.) propagate to parent components
137
+ if (key.upArrow || input === 'k') {
138
+ // Move up but don't loop - stop at 0
139
+ updateSelectedIndex((current) => {
140
+ const newIndex = Math.max(0, current - 1);
141
+
142
+ // Adjust offset if needed for scrolling
143
+ if (limit && newIndex < offset) {
144
+ setOffset(newIndex);
145
+ }
146
+
147
+ return newIndex;
148
+ });
149
+ } else if (key.downArrow || input === 'j') {
150
+ // Move down but don't loop - stop at last item
151
+ updateSelectedIndex((current) => {
152
+ const newIndex = Math.min(items.length - 1, current + 1);
153
+
154
+ // Adjust offset if needed for scrolling
155
+ if (limit && newIndex >= offset + limit) {
156
+ setOffset(newIndex - limit + 1);
157
+ }
158
+
159
+ return newIndex;
160
+ });
161
+ } else if (key.return) {
162
+ // Select current item
163
+ const selectedItem = items[selectedIndex];
164
+ if (selectedItem && !disabled) {
165
+ onSelect(selectedItem);
166
+ }
167
+ }
168
+ // All other keys are ignored and will propagate to parent components
169
+ });
170
+
171
+ // Determine visible items based on limit
172
+ const visibleItems = limit ? items.slice(offset, offset + limit) : items;
173
+ const visibleStartIndex = limit ? offset : 0;
174
+
175
+ const { stdout } = useStdout();
176
+ const columns = stdout?.columns ?? 80;
177
+
178
+ return (
179
+ <Box flexDirection="column">
180
+ {visibleItems.map((item, index) => {
181
+ const actualIndex = visibleStartIndex + index;
182
+ const isSelected = actualIndex === selectedIndex;
183
+
184
+ if (renderItem) {
185
+ return (
186
+ <Box key={item.value} flexDirection="row">
187
+ {renderItem(item, isSelected, { columns })}
188
+ </Box>
189
+ );
190
+ }
191
+
192
+ const indicatorElement = renderIndicator
193
+ ? renderIndicator(item, isSelected)
194
+ : isSelected
195
+ ? <Text color="cyan">›</Text>
196
+ : <Text> </Text>;
197
+
198
+ return (
199
+ <Box key={item.value} flexDirection="row">
200
+ <Box marginRight={1}>{indicatorElement ?? <Text> </Text>}</Box>
201
+ {isSelected ? (
202
+ <Text color="cyan">{item.label}</Text>
203
+ ) : (
204
+ <Text>{item.label}</Text>
205
+ )}
206
+ </Box>
207
+ );
208
+ })}
209
+ </Box>
210
+ );
211
+ };
212
+
213
+ /**
214
+ * Export memoized Select component
215
+ */
216
+ export const Select = React.memo(SelectComponent, arePropsEqual) as typeof SelectComponent;
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ export interface FooterAction {
5
+ key: string;
6
+ description: string;
7
+ }
8
+
9
+ export interface FooterProps {
10
+ actions: FooterAction[];
11
+ separator?: string;
12
+ }
13
+
14
+ /**
15
+ * Footer component - displays keyboard actions
16
+ * Optimized with React.memo to prevent unnecessary re-renders
17
+ */
18
+ export const Footer = React.memo(function Footer({
19
+ actions,
20
+ separator = " ",
21
+ }: FooterProps) {
22
+ if (actions.length === 0) {
23
+ return null;
24
+ }
25
+
26
+ return (
27
+ <Box>
28
+ {actions.map((action, index) => (
29
+ <Box key={`${action.key}-${index}`}>
30
+ <Text dimColor>[</Text>
31
+ <Text bold color="cyan">
32
+ {action.key}
33
+ </Text>
34
+ <Text dimColor>]</Text>
35
+ <Text> {action.description}</Text>
36
+ {index < actions.length - 1 && <Text dimColor>{separator}</Text>}
37
+ </Box>
38
+ ))}
39
+ </Box>
40
+ );
41
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "ink-testing-library";
3
+ import React from "react";
4
+ import { Header } from "./Header.js";
5
+
6
+ describe("Header Component", () => {
7
+ it("正常系: versionプロップありの場合、タイトルとバージョンを表示する", () => {
8
+ const { lastFrame } = render(
9
+ <Header title="gwt" version="1.12.3" />
10
+ );
11
+
12
+ const output = lastFrame();
13
+
14
+ // タイトルとバージョンが含まれることを確認
15
+ expect(output).toContain("gwt v1.12.3");
16
+ });
17
+
18
+ it("正常系: versionプロップなし(undefined)の場合、タイトルのみ表示する", () => {
19
+ const { lastFrame } = render(<Header title="gwt" />);
20
+
21
+ const output = lastFrame();
22
+
23
+ // タイトルのみが含まれることを確認
24
+ expect(output).toContain("gwt");
25
+ // "v"が含まれていないことを確認(バージョンが表示されていない)
26
+ expect(output).not.toMatch(/v\d+\.\d+\.\d+/);
27
+ });
28
+
29
+ it("正常系: version={null}の場合、タイトルのみ表示する", () => {
30
+ const { lastFrame } = render(
31
+ <Header title="gwt" version={null} />
32
+ );
33
+
34
+ const output = lastFrame();
35
+
36
+ // タイトルのみが含まれることを確認
37
+ expect(output).toContain("gwt");
38
+ // "v"が含まれていないことを確認(バージョンが表示されていない)
39
+ expect(output).not.toMatch(/v\d+\.\d+\.\d+/);
40
+ });
41
+
42
+ it("正常系: showDivider=trueの場合、区切り線が表示される", () => {
43
+ const { lastFrame } = render(
44
+ <Header
45
+ title="gwt"
46
+ version="1.12.3"
47
+ showDivider={true}
48
+ dividerChar="─"
49
+ />
50
+ );
51
+
52
+ const output = lastFrame();
53
+
54
+ // 区切り線が含まれることを確認
55
+ expect(output).toContain("─");
56
+ });
57
+
58
+ it("正常系: showDivider=falseの場合、区切り線が表示されない", () => {
59
+ const { lastFrame } = render(
60
+ <Header
61
+ title="gwt"
62
+ version="1.12.3"
63
+ showDivider={false}
64
+ />
65
+ );
66
+
67
+ const output = lastFrame();
68
+
69
+ // タイトルとバージョンは含まれる
70
+ expect(output).toContain("gwt v1.12.3");
71
+ // 区切り線が含まれないことを確認(または最小限)
72
+ // 注: Inkのレンダリング結果によっては、完全に区切り線がないとは限らない
73
+ });
74
+
75
+ it("正常系: プレリリースバージョンも正しく表示される", () => {
76
+ const { lastFrame } = render(
77
+ <Header title="gwt" version="2.0.0-beta.1" />
78
+ );
79
+
80
+ const output = lastFrame();
81
+
82
+ // プレリリースバージョンが含まれることを確認
83
+ expect(output).toContain("gwt v2.0.0-beta.1");
84
+ });
85
+ });
@@ -0,0 +1,63 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ export interface HeaderProps {
5
+ title: string;
6
+ titleColor?: string;
7
+ dividerChar?: string;
8
+ showDivider?: boolean;
9
+ width?: number;
10
+ /**
11
+ * アプリケーションのバージョン文字列
12
+ * - string: バージョンが利用可能(例: "1.12.3")
13
+ * - null: バージョン取得失敗
14
+ * - undefined: バージョン未提供(後方互換性のため)
15
+ * @default undefined
16
+ */
17
+ version?: string | null | undefined;
18
+ /**
19
+ * 起動時の作業ディレクトリの絶対パス
20
+ * - string: ディレクトリパスが利用可能
21
+ * - undefined: ディレクトリ情報未提供
22
+ * @default undefined
23
+ */
24
+ workingDirectory?: string;
25
+ }
26
+
27
+ /**
28
+ * Header component - displays title and optional divider
29
+ * Optimized with React.memo to prevent unnecessary re-renders
30
+ */
31
+ export const Header = React.memo(function Header({
32
+ title,
33
+ titleColor = "cyan",
34
+ dividerChar = "─",
35
+ showDivider = true,
36
+ width = 80,
37
+ version,
38
+ workingDirectory,
39
+ }: HeaderProps) {
40
+ const divider = dividerChar.repeat(width);
41
+ const displayTitle = version ? `${title} v${version}` : title;
42
+
43
+ return (
44
+ <Box flexDirection="column">
45
+ <Box>
46
+ <Text bold color={titleColor}>
47
+ {displayTitle}
48
+ </Text>
49
+ </Box>
50
+ {showDivider && (
51
+ <Box>
52
+ <Text dimColor>{divider}</Text>
53
+ </Box>
54
+ )}
55
+ {workingDirectory && (
56
+ <Box>
57
+ <Text dimColor>Working Directory: </Text>
58
+ <Text>{workingDirectory}</Text>
59
+ </Box>
60
+ )}
61
+ </Box>
62
+ );
63
+ });