@akiojin/gwt 4.5.1 → 4.6.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.
@@ -10,6 +10,7 @@ import { useSpinnerFrame } from "../common/SpinnerIcon.js";
10
10
  import { useAppInput } from "../../hooks/useAppInput.js";
11
11
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
12
12
  import type { BranchItem, Statistics, BranchViewMode } from "../../types.js";
13
+ import type { ToolStatus } from "../../hooks/useToolStatus.js";
13
14
  import stringWidth from "string-width";
14
15
  import stripAnsi from "strip-ansi";
15
16
  import chalk from "chalk";
@@ -109,13 +110,18 @@ export interface BranchListScreenProps {
109
110
  testOnViewModeChange?: (mode: BranchViewMode) => void;
110
111
  selectedBranches?: string[];
111
112
  onToggleSelect?: (branchName: string) => void;
113
+ /**
114
+ * AIツールのインストール状態配列
115
+ * @see specs/SPEC-3b0ed29b/spec.md FR-019, FR-021
116
+ */
117
+ toolStatuses?: ToolStatus[] | undefined;
112
118
  }
113
119
 
114
120
  /**
115
121
  * BranchListScreen - Main screen for branch selection
116
122
  * Layout: Header + Stats + Branch List + Footer
117
123
  */
118
- export function BranchListScreen({
124
+ export const BranchListScreen = React.memo(function BranchListScreen({
119
125
  branches,
120
126
  stats,
121
127
  onSelect,
@@ -138,6 +144,7 @@ export function BranchListScreen({
138
144
  testOnViewModeChange,
139
145
  selectedBranches = [],
140
146
  onToggleSelect,
147
+ toolStatuses,
141
148
  }: BranchListScreenProps) {
142
149
  const { rows } = useTerminalSize();
143
150
  const selectedSet = useMemo(
@@ -274,8 +281,14 @@ export function BranchListScreen({
274
281
  let result = branches;
275
282
 
276
283
  // Apply view mode filter
277
- if (viewMode !== "all") {
278
- result = result.filter((branch) => branch.type === viewMode);
284
+ if (viewMode === "local") {
285
+ result = result.filter((branch) => branch.type === "local");
286
+ } else if (viewMode === "remote") {
287
+ // リモート専用ブランチ OR ローカルだがリモートにも存在するブランチ
288
+ result = result.filter(
289
+ (branch) =>
290
+ branch.type === "remote" || branch.hasRemoteCounterpart === true,
291
+ );
279
292
  }
280
293
 
281
294
  // Apply search filter
@@ -446,11 +459,9 @@ export function BranchListScreen({
446
459
  const indicatorInfo = cleanupUI?.indicators?.[item.name];
447
460
  let leadingIndicator: string;
448
461
  if (indicatorInfo) {
449
- // Use spinner frame if isSpinning, otherwise use static icon
450
- let indicatorIcon =
451
- indicatorInfo.isSpinning && spinnerFrame
452
- ? spinnerFrame
453
- : indicatorInfo.icon;
462
+ // Use static spinner icon if isSpinning to avoid re-render dependency
463
+ // The static "⠋" provides visual feedback without causing flicker
464
+ let indicatorIcon = indicatorInfo.isSpinning ? "⠋" : indicatorInfo.icon;
454
465
  if (indicatorIcon && indicatorInfo.color && !isSelected) {
455
466
  switch (indicatorInfo.color) {
456
467
  case "cyan":
@@ -588,7 +599,6 @@ export function BranchListScreen({
588
599
  truncateToWidth,
589
600
  selectedSet,
590
601
  colorToolLabel,
591
- spinnerFrame,
592
602
  ],
593
603
  );
594
604
 
@@ -625,6 +635,22 @@ export function BranchListScreen({
625
635
  )}
626
636
  </Box>
627
637
 
638
+ {/* Tool Status - FR-019, FR-021 */}
639
+ {toolStatuses && toolStatuses.length > 0 && (
640
+ <Box>
641
+ <Text dimColor>Tools: </Text>
642
+ {toolStatuses.map((tool, index) => (
643
+ <React.Fragment key={tool.id}>
644
+ <Text>{tool.name}: </Text>
645
+ <Text color={tool.status === "installed" ? "green" : "yellow"}>
646
+ {tool.status}
647
+ </Text>
648
+ {index < toolStatuses.length - 1 && <Text dimColor> | </Text>}
649
+ </React.Fragment>
650
+ ))}
651
+ </Box>
652
+ )}
653
+
628
654
  {/* Stats */}
629
655
  <Box>
630
656
  <Stats stats={stats} lastUpdated={lastUpdated} viewMode={viewMode} />
@@ -708,4 +734,4 @@ export function BranchListScreen({
708
734
  <Footer actions={footerActions} />
709
735
  </Box>
710
736
  );
711
- }
737
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * AIツール状態管理フック
3
+ *
4
+ * 各AIツール(Claude Code、Codex、Gemini)のインストール状態を
5
+ * gwt起動時に検出し、キャッシュして提供します。
6
+ * @see specs/SPEC-3b0ed29b/spec.md FR-017〜FR-021
7
+ */
8
+
9
+ import { useState, useEffect, useCallback } from "react";
10
+ import {
11
+ detectAllToolStatuses,
12
+ type ToolStatus,
13
+ } from "../../../utils/command.js";
14
+
15
+ export interface UseToolStatusResult {
16
+ /** ツール状態の配列(ロード中は空配列) */
17
+ tools: ToolStatus[];
18
+ /** ロード中フラグ */
19
+ loading: boolean;
20
+ /** エラー(なければnull) */
21
+ error: Error | null;
22
+ /** ツール状態を再検出(通常は不要、デバッグ用) */
23
+ refresh: () => Promise<void>;
24
+ }
25
+
26
+ /**
27
+ * AIツール状態管理フック
28
+ *
29
+ * コンポーネントでAIツールのインストール状態を取得するためのフック。
30
+ * 初回マウント時に自動的に全ツールの状態を検出してキャッシュします。
31
+ *
32
+ * キャッシュされた結果は、ブランチ選択時やツール起動時に再利用され、
33
+ * 毎回の検出オーバーヘッドを削減します(FR-020)。
34
+ */
35
+ export function useToolStatus(): UseToolStatusResult {
36
+ const [tools, setTools] = useState<ToolStatus[]>([]);
37
+ const [loading, setLoading] = useState(true);
38
+ const [error, setError] = useState<Error | null>(null);
39
+
40
+ // ツール状態を検出
41
+ const refresh = useCallback(async () => {
42
+ try {
43
+ setLoading(true);
44
+ setError(null);
45
+ const statuses = await detectAllToolStatuses();
46
+ setTools(statuses);
47
+ } catch (err) {
48
+ setError(err instanceof Error ? err : new Error(String(err)));
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ }, []);
53
+
54
+ // 初回マウント時に検出(FR-017: 起動時に検出してキャッシュ)
55
+ useEffect(() => {
56
+ refresh();
57
+ }, [refresh]);
58
+
59
+ return {
60
+ tools,
61
+ loading,
62
+ error,
63
+ refresh,
64
+ };
65
+ }
66
+
67
+ // Re-export ToolStatus type for convenience
68
+ export type { ToolStatus };
package/src/gemini.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  getTerminalStreams,
7
7
  resetTerminalModes,
8
8
  } from "./utils/terminal.js";
9
- import { isCommandAvailable } from "./utils/command.js";
9
+ import { findCommand } from "./utils/command.js";
10
10
  import { findLatestGeminiSessionId } from "./utils/session.js";
11
11
 
12
12
  const GEMINI_CLI_PACKAGE = "@google/gemini-cli@latest";
@@ -152,7 +152,7 @@ export async function launchGeminiCLI(
152
152
  const childStdio = createChildStdio();
153
153
 
154
154
  // Auto-detect locally installed gemini command
155
- const hasLocalGemini = await isCommandAvailable("gemini");
155
+ const geminiLookup = await findCommand("gemini");
156
156
 
157
157
  // Preserve TTY for interactive UI (colors/width) by inheriting stdout/stderr.
158
158
  // Session ID is determined via file-based detection after exit.
@@ -184,11 +184,12 @@ export async function launchGeminiCLI(
184
184
  await execChild(child);
185
185
  };
186
186
 
187
- if (hasLocalGemini) {
187
+ if (geminiLookup.source === "installed" && geminiLookup.path) {
188
+ // Use the full path to avoid PATH issues in non-interactive shells
188
189
  console.log(
189
190
  chalk.green(" ✨ Using locally installed gemini command"),
190
191
  );
191
- return await run("gemini", runArgs);
192
+ return await run(geminiLookup.path, runArgs);
192
193
  }
193
194
  console.log(
194
195
  chalk.cyan(" 🔄 Falling back to bunx @google/gemini-cli@latest"),
@@ -263,7 +264,9 @@ export async function launchGeminiCLI(
263
264
 
264
265
  return capturedSessionId ? { sessionId: capturedSessionId } : {};
265
266
  } catch (error: unknown) {
266
- const hasLocalGemini = await isCommandAvailable("gemini");
267
+ const geminiCheck = await findCommand("gemini");
268
+ const hasLocalGemini =
269
+ geminiCheck.source === "installed" && geminiCheck.path !== null;
267
270
  let errorMessage: string;
268
271
  const err = error as NodeJS.ErrnoException;
269
272
 
package/src/index.ts CHANGED
@@ -267,6 +267,7 @@ async function mainInkUI(): Promise<SelectionResult | undefined> {
267
267
  stdin: terminal.stdin,
268
268
  stdout: terminal.stdout,
269
269
  stderr: terminal.stderr,
270
+ patchConsole: false,
270
271
  },
271
272
  );
272
273
 
@@ -1,26 +1,188 @@
1
1
  import { execa } from "execa";
2
+ import { existsSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
2
5
 
3
6
  /**
4
- * Checks whether a command is available in the current PATH.
5
- *
6
- * Uses `where` on Windows and `which` on other platforms.
7
+ * Known installation paths for common AI CLI tools.
8
+ * These are checked as fallback when `which`/`where` fails.
9
+ */
10
+ const KNOWN_INSTALL_PATHS: Record<string, { unix: string[]; win32: string[] }> =
11
+ {
12
+ claude: {
13
+ unix: [
14
+ join(homedir(), ".bun", "bin", "claude"),
15
+ join(homedir(), ".local", "bin", "claude"),
16
+ "/usr/local/bin/claude",
17
+ ],
18
+ win32: [
19
+ join(
20
+ process.env.LOCALAPPDATA ?? "",
21
+ "Programs",
22
+ "claude",
23
+ "claude.exe",
24
+ ),
25
+ join(homedir(), ".bun", "bin", "claude.exe"),
26
+ ],
27
+ },
28
+ codex: {
29
+ unix: [
30
+ join(homedir(), ".bun", "bin", "codex"),
31
+ join(homedir(), ".local", "bin", "codex"),
32
+ "/usr/local/bin/codex",
33
+ ],
34
+ win32: [join(homedir(), ".bun", "bin", "codex.exe")],
35
+ },
36
+ gemini: {
37
+ unix: [
38
+ join(homedir(), ".bun", "bin", "gemini"),
39
+ join(homedir(), ".local", "bin", "gemini"),
40
+ "/usr/local/bin/gemini",
41
+ ],
42
+ win32: [join(homedir(), ".bun", "bin", "gemini.exe")],
43
+ },
44
+ };
45
+
46
+ /**
47
+ * Builtin AI tools with their command names and display names.
48
+ */
49
+ const BUILTIN_TOOLS = [
50
+ { id: "claude-code", commandName: "claude", displayName: "Claude" },
51
+ { id: "codex-cli", commandName: "codex", displayName: "Codex" },
52
+ { id: "gemini-cli", commandName: "gemini", displayName: "Gemini" },
53
+ ] as const;
54
+
55
+ /**
56
+ * Result of command lookup.
57
+ */
58
+ export interface CommandLookupResult {
59
+ available: boolean;
60
+ path: string | null;
61
+ source: "installed" | "bunx";
62
+ }
63
+
64
+ /**
65
+ * Tool status information for display.
66
+ */
67
+ export interface ToolStatus {
68
+ id: string;
69
+ name: string;
70
+ status: "installed" | "bunx";
71
+ path: string | null;
72
+ }
73
+
74
+ /**
75
+ * Module-level cache for command lookup results.
76
+ * This cache persists for the lifetime of the process (FR-020).
77
+ */
78
+ const commandLookupCache = new Map<string, CommandLookupResult>();
79
+
80
+ /**
81
+ * Clears the command lookup cache.
82
+ * Primarily for testing purposes.
83
+ */
84
+ export function clearCommandLookupCache(): void {
85
+ commandLookupCache.clear();
86
+ }
87
+
88
+ /**
89
+ * Finds a command by checking PATH first, then fallback paths.
90
+ * Results are cached for the lifetime of the process (FR-020).
7
91
  *
8
- * @param commandName - Command name to look up (e.g. `claude`, `npx`, `gemini`)
9
- * @returns true if the command exists in PATH
92
+ * @param commandName - Command name to look up (e.g. `claude`, `codex`, `gemini`)
93
+ * @returns CommandLookupResult with availability, path, and source
10
94
  */
11
- export async function isCommandAvailable(
95
+ export async function findCommand(
12
96
  commandName: string,
13
- ): Promise<boolean> {
97
+ ): Promise<CommandLookupResult> {
98
+ // Check cache first (FR-020: 再検出を行わない)
99
+ const cached = commandLookupCache.get(commandName);
100
+ if (cached) {
101
+ return cached;
102
+ }
103
+
104
+ let lookupResult: CommandLookupResult | null = null;
105
+
106
+ // Step 1: Try standard which/where lookup
14
107
  try {
15
- const command = process.platform === "win32" ? "where" : "which";
16
- await execa(command, [commandName], {
108
+ const lookupCommand = process.platform === "win32" ? "where" : "which";
109
+ const execResult = await execa(lookupCommand, [commandName], {
17
110
  shell: true,
18
111
  stdin: "ignore",
19
- stdout: "ignore",
112
+ stdout: "pipe",
20
113
  stderr: "ignore",
21
114
  });
22
- return true;
115
+ const foundPath = execResult.stdout.trim().split("\n")[0];
116
+ if (foundPath) {
117
+ lookupResult = { available: true, path: foundPath, source: "installed" };
118
+ }
23
119
  } catch {
24
- return false;
120
+ // which/where failed, try fallback paths
121
+ }
122
+
123
+ // Step 2: Check known installation paths as fallback
124
+ if (!lookupResult) {
125
+ const knownPaths = KNOWN_INSTALL_PATHS[commandName];
126
+ if (knownPaths) {
127
+ const pathsToCheck =
128
+ process.platform === "win32" ? knownPaths.win32 : knownPaths.unix;
129
+
130
+ for (const p of pathsToCheck) {
131
+ if (p && existsSync(p)) {
132
+ lookupResult = { available: true, path: p, source: "installed" };
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // Step 3: Fall back to bunx (always available for known tools)
140
+ if (!lookupResult) {
141
+ lookupResult = { available: true, path: null, source: "bunx" };
25
142
  }
143
+
144
+ // Cache the result (FR-020)
145
+ commandLookupCache.set(commandName, lookupResult);
146
+
147
+ return lookupResult;
148
+ }
149
+
150
+ /**
151
+ * Checks whether a command is available in the current PATH.
152
+ *
153
+ * Uses `where` on Windows and `which` on other platforms.
154
+ * If the standard lookup fails, checks known installation paths
155
+ * as a fallback for common tools.
156
+ *
157
+ * @param commandName - Command name to look up (e.g. `claude`, `npx`, `gemini`)
158
+ * @returns true if the command exists in PATH or known paths
159
+ */
160
+ export async function isCommandAvailable(
161
+ commandName: string,
162
+ ): Promise<boolean> {
163
+ const result = await findCommand(commandName);
164
+ return result.available;
165
+ }
166
+
167
+ /**
168
+ * Detects installation status for all builtin AI tools.
169
+ *
170
+ * This function is designed to be called once at application startup
171
+ * and cached for the duration of the session.
172
+ *
173
+ * @returns Array of ToolStatus for all builtin tools
174
+ */
175
+ export async function detectAllToolStatuses(): Promise<ToolStatus[]> {
176
+ const results = await Promise.all(
177
+ BUILTIN_TOOLS.map(async (tool) => {
178
+ const result = await findCommand(tool.commandName);
179
+ return {
180
+ id: tool.id,
181
+ name: tool.displayName,
182
+ status: result.source,
183
+ path: result.path,
184
+ };
185
+ }),
186
+ );
187
+ return results;
26
188
  }