@akiojin/gwt 2.7.4 → 2.9.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 (63) hide show
  1. package/dist/cli/ui/components/App.d.ts.map +1 -1
  2. package/dist/cli/ui/components/App.js +17 -1
  3. package/dist/cli/ui/components/App.js.map +1 -1
  4. package/dist/cli/ui/components/screens/AIToolSelectorScreen.d.ts +2 -1
  5. package/dist/cli/ui/components/screens/AIToolSelectorScreen.d.ts.map +1 -1
  6. package/dist/cli/ui/components/screens/AIToolSelectorScreen.js +18 -3
  7. package/dist/cli/ui/components/screens/AIToolSelectorScreen.js.map +1 -1
  8. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  9. package/dist/cli/ui/components/screens/BranchListScreen.js +5 -1
  10. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  11. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  12. package/dist/cli/ui/hooks/useGitData.js +16 -4
  13. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  14. package/dist/cli/ui/types.d.ts +4 -0
  15. package/dist/cli/ui/types.d.ts.map +1 -1
  16. package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
  17. package/dist/cli/ui/utils/branchFormatter.js +47 -0
  18. package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
  19. package/dist/client/assets/{index-DxHGLTNq.js → index-CNWntAlF.js} +15 -15
  20. package/dist/client/index.html +1 -1
  21. package/dist/config/index.d.ts +17 -0
  22. package/dist/config/index.d.ts.map +1 -1
  23. package/dist/config/index.js +66 -1
  24. package/dist/config/index.js.map +1 -1
  25. package/dist/git.d.ts.map +1 -1
  26. package/dist/git.js +114 -30
  27. package/dist/git.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/types/api.d.ts +11 -0
  32. package/dist/types/api.d.ts.map +1 -1
  33. package/dist/web/client/src/pages/BranchDetailPage.d.ts.map +1 -1
  34. package/dist/web/client/src/pages/BranchDetailPage.js +55 -0
  35. package/dist/web/client/src/pages/BranchDetailPage.js.map +1 -1
  36. package/dist/web/server/routes/sessions.d.ts.map +1 -1
  37. package/dist/web/server/routes/sessions.js +35 -0
  38. package/dist/web/server/routes/sessions.js.map +1 -1
  39. package/dist/web/server/services/branches.d.ts.map +1 -1
  40. package/dist/web/server/services/branches.js +4 -0
  41. package/dist/web/server/services/branches.js.map +1 -1
  42. package/dist/worktree.d.ts.map +1 -1
  43. package/dist/worktree.js +3 -1
  44. package/dist/worktree.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +20 -0
  47. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +53 -0
  48. package/src/cli/ui/__tests__/integration/navigation.test.tsx +51 -0
  49. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +37 -0
  50. package/src/cli/ui/components/App.tsx +21 -0
  51. package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +27 -2
  52. package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
  53. package/src/cli/ui/hooks/useGitData.ts +15 -4
  54. package/src/cli/ui/types.ts +5 -0
  55. package/src/cli/ui/utils/branchFormatter.ts +47 -0
  56. package/src/config/index.ts +87 -1
  57. package/src/git.ts +114 -30
  58. package/src/index.ts +4 -1
  59. package/src/types/api.ts +12 -0
  60. package/src/web/client/src/pages/BranchDetailPage.tsx +69 -1
  61. package/src/web/server/routes/sessions.ts +39 -0
  62. package/src/web/server/services/branches.ts +5 -0
  63. package/src/worktree.ts +7 -1
package/src/git.ts CHANGED
@@ -58,27 +58,103 @@ export async function isGitRepository(): Promise<boolean> {
58
58
  * @throws {GitError} リポジトリルートの取得に失敗した場合
59
59
  */
60
60
  export async function getRepositoryRoot(): Promise<string> {
61
+ const fs = await import("node:fs");
62
+ const path = await import("node:path");
63
+
64
+ const toExistingDir = (p: string): string => {
65
+ let current = path.resolve(p);
66
+ while (!fs.existsSync(current) && path.dirname(current) !== current) {
67
+ current = path.dirname(current);
68
+ }
69
+ if (!fs.existsSync(current)) {
70
+ return current;
71
+ }
72
+ try {
73
+ const stat = fs.statSync(current);
74
+ return stat.isDirectory() ? current : path.dirname(current);
75
+ } catch {
76
+ return current;
77
+ }
78
+ };
79
+
80
+ // 1) show-toplevel を最優先
81
+ try {
82
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
83
+ const top = stdout.trim();
84
+ if (top) {
85
+ const marker = `${path.sep}.worktrees${path.sep}`;
86
+ const idx = top.indexOf(marker);
87
+ if (idx >= 0) {
88
+ // /repo/.worktrees/<name> → repo root = /repo
89
+ const parent = top.slice(0, idx);
90
+ const upTwo = path.resolve(top, "..", "..");
91
+ if (fs.existsSync(parent)) return toExistingDir(parent);
92
+ if (fs.existsSync(upTwo)) return toExistingDir(upTwo);
93
+ }
94
+ if (fs.existsSync(top)) {
95
+ return toExistingDir(top);
96
+ }
97
+ return toExistingDir(path.resolve(top, ".."));
98
+ }
99
+ } catch {
100
+ // fallback
101
+ }
102
+
103
+ // 2) git-common-dir 経由
61
104
  try {
62
- // git rev-parse --git-common-dirを使用してメインリポジトリの.gitディレクトリを取得
63
105
  const { stdout: gitCommonDir } = await execa("git", [
64
106
  "rev-parse",
65
107
  "--git-common-dir",
66
108
  ]);
67
- const gitDir = gitCommonDir.trim();
68
-
69
- // .gitディレクトリの親ディレクトリがリポジトリルート
70
- const path = await import("node:path");
71
- const repoRoot = path.dirname(gitDir);
72
-
73
- // 相対パスが返された場合(.gitなど)は、現在のディレクトリからの相対パスとして解決
74
- if (!path.isAbsolute(repoRoot)) {
75
- return path.resolve(repoRoot);
109
+ const gitDir = path.resolve(gitCommonDir.trim());
110
+ const parts = gitDir.split(path.sep);
111
+ const idxWorktrees = parts.lastIndexOf("worktrees");
112
+ if (idxWorktrees > 0) {
113
+ const repo = parts.slice(0, idxWorktrees).join(path.sep);
114
+ if (fs.existsSync(repo)) return toExistingDir(repo);
76
115
  }
116
+ const idxGit = parts.lastIndexOf(".git");
117
+ if (idxGit > 0) {
118
+ const repo = parts.slice(0, idxGit).join(path.sep);
119
+ if (fs.existsSync(repo)) return toExistingDir(repo);
120
+ }
121
+ return toExistingDir(path.dirname(gitDir));
122
+ } catch {
123
+ // fallback
124
+ }
77
125
 
78
- return repoRoot;
79
- } catch (error) {
80
- throw new GitError("Failed to get repository root", error);
126
+ // 3) .git を上に辿って探す
127
+ let current = process.cwd();
128
+ const root = path.parse(current).root;
129
+ while (true) {
130
+ const candidate = path.join(current, ".git");
131
+ if (fs.existsSync(candidate)) {
132
+ if (fs.statSync(candidate).isDirectory()) {
133
+ return toExistingDir(current);
134
+ }
135
+ try {
136
+ const content = fs.readFileSync(candidate, "utf-8");
137
+ const match = content.match(/gitdir:\s*(.+)\s*/i);
138
+ const gitDirMatch = match?.[1];
139
+ if (gitDirMatch) {
140
+ const gitDirPath = path.resolve(current, gitDirMatch.trim());
141
+ const parts = gitDirPath.split(path.sep);
142
+ const idxWT = parts.lastIndexOf("worktrees");
143
+ if (idxWT > 0) {
144
+ const repo = parts.slice(0, idxWT).join(path.sep);
145
+ return toExistingDir(repo);
146
+ }
147
+ return toExistingDir(path.dirname(gitDirPath));
148
+ }
149
+ } catch {
150
+ // ignore and continue
151
+ }
152
+ }
153
+ if (current === root) break;
154
+ current = path.dirname(current);
81
155
  }
156
+
157
+ throw new GitError("Failed to get repository root");
82
158
  }
83
159
 
84
160
  /**
@@ -97,22 +173,33 @@ export async function getWorktreeRoot(): Promise<string> {
97
173
 
98
174
  export async function getCurrentBranch(): Promise<string | null> {
99
175
  try {
100
- const { stdout } = await execa("git", ["branch", "--show-current"]);
176
+ const repoRoot = await getRepositoryRoot();
177
+ const { stdout } = await execa(
178
+ "git",
179
+ ["branch", "--show-current"],
180
+ { cwd: repoRoot },
181
+ );
101
182
  return stdout.trim() || null;
102
183
  } catch {
103
- return null;
184
+ try {
185
+ const { stdout } = await execa("git", ["branch", "--show-current"]);
186
+ return stdout.trim() || null;
187
+ } catch {
188
+ return null;
189
+ }
104
190
  }
105
191
  }
106
192
 
107
193
  async function getBranchCommitTimestamps(
108
194
  refs: string[],
195
+ cwd?: string,
109
196
  ): Promise<Map<string, number>> {
110
197
  try {
111
- const { stdout } = await execa("git", [
198
+ const { stdout = "" } = (await execa("git", [
112
199
  "for-each-ref",
113
200
  "--format=%(refname:short)%00%(committerdate:unix)",
114
201
  ...refs,
115
- ]);
202
+ ], cwd ? { cwd } : undefined)) ?? { stdout: "" };
116
203
 
117
204
  const map = new Map<string, number>();
118
205
 
@@ -135,10 +222,9 @@ async function getBranchCommitTimestamps(
135
222
  export async function getLocalBranches(): Promise<BranchInfo[]> {
136
223
  try {
137
224
  const commitMap = await getBranchCommitTimestamps(["refs/heads"]);
138
- const { stdout } = await execa("git", [
139
- "branch",
140
- "--format=%(refname:short)",
141
- ]);
225
+ const { stdout = "" } =
226
+ (await execa("git", ["branch", "--format=%(refname:short)"])) ??
227
+ { stdout: "" };
142
228
  return stdout
143
229
  .split("\n")
144
230
  .filter((line) => line.trim())
@@ -156,19 +242,17 @@ export async function getLocalBranches(): Promise<BranchInfo[]> {
156
242
  : {}),
157
243
  } satisfies BranchInfo;
158
244
  });
159
- } catch (error) {
160
- throw new GitError("Failed to get local branches", error);
245
+ } catch (primaryError) {
246
+ throw new GitError("Failed to get local branches", primaryError);
161
247
  }
162
248
  }
163
249
 
164
250
  export async function getRemoteBranches(): Promise<BranchInfo[]> {
165
251
  try {
166
252
  const commitMap = await getBranchCommitTimestamps(["refs/remotes"]);
167
- const { stdout } = await execa("git", [
168
- "branch",
169
- "-r",
170
- "--format=%(refname:short)",
171
- ]);
253
+ const { stdout = "" } =
254
+ (await execa("git", ["branch", "-r", "--format=%(refname:short)"])) ??
255
+ { stdout: "" };
172
256
  return stdout
173
257
  .split("\n")
174
258
  .filter((line) => line.trim() && !line.includes("HEAD"))
@@ -187,8 +271,8 @@ export async function getRemoteBranches(): Promise<BranchInfo[]> {
187
271
  : {}),
188
272
  } satisfies BranchInfo;
189
273
  });
190
- } catch (error) {
191
- throw new GitError("Failed to get remote branches", error);
274
+ } catch (primaryError) {
275
+ throw new GitError("Failed to get remote branches", primaryError);
192
276
  }
193
277
  }
194
278
 
package/src/index.ts CHANGED
@@ -665,11 +665,14 @@ export async function handleAIToolWorkflow(
665
665
  });
666
666
  }
667
667
 
668
- // Save session with lastUsedTool
668
+ // Save session with lastUsedTool (tool selection is confirmed at this point)
669
669
  await saveSession({
670
670
  lastWorktreePath: worktreePath,
671
671
  lastBranch: branch,
672
672
  lastUsedTool: tool,
673
+ toolLabel: toolConfig.displayName ?? tool,
674
+ mode,
675
+ model: model ?? null,
673
676
  timestamp: Date.now(),
674
677
  repositoryRoot: repoRoot,
675
678
  });
package/src/types/api.ts CHANGED
@@ -30,6 +30,7 @@ export interface Branch {
30
30
  state: "open" | "merged" | "closed";
31
31
  mergedAt?: string | null;
32
32
  } | null;
33
+ lastToolUsage?: LastToolUsage | null;
33
34
  }
34
35
 
35
36
  /**
@@ -46,6 +47,7 @@ export interface Worktree {
46
47
  lastAccessedAt?: string | null; // ISO8601
47
48
  divergence?: Branch["divergence"];
48
49
  prInfo?: Branch["prInfo"];
50
+ lastToolUsage?: LastToolUsage | null;
49
51
  }
50
52
 
51
53
  /**
@@ -102,6 +104,16 @@ export interface CustomAITool {
102
104
  updatedAt?: string | null; // ISO8601
103
105
  }
104
106
 
107
+ export interface LastToolUsage {
108
+ branch: string;
109
+ worktreePath: string | null;
110
+ toolId: string;
111
+ toolLabel: string;
112
+ mode?: "normal" | "continue" | "resume" | null;
113
+ model?: string | null;
114
+ timestamp: number; // epoch millis
115
+ }
116
+
105
117
  /**
106
118
  * REST API Response wrappers
107
119
  */
@@ -10,7 +10,11 @@ import {
10
10
  import { useConfig } from "../hooks/useConfig";
11
11
  import { ApiError } from "../lib/api";
12
12
  import { Terminal } from "../components/Terminal";
13
- import type { Branch, CustomAITool } from "../../../../types/api.js";
13
+ import type {
14
+ Branch,
15
+ CustomAITool,
16
+ LastToolUsage,
17
+ } from "../../../../types/api.js";
14
18
 
15
19
  type ToolType = "claude-code" | "codex-cli" | "custom";
16
20
  type ToolMode = "normal" | "continue" | "resume";
@@ -410,6 +414,29 @@ export function BranchDetailPage() {
410
414
  .sort((a, b) => (b.startedAt ?? "").localeCompare(a.startedAt ?? ""));
411
415
  }, [sessionsData, branch?.worktreePath]);
412
416
 
417
+ const latestToolUsage: LastToolUsage | null = useMemo(() => {
418
+ if (branch?.lastToolUsage) {
419
+ return branch.lastToolUsage;
420
+ }
421
+ const first = branchSessions[0];
422
+ if (!first) return null;
423
+ return {
424
+ branch: branch.name,
425
+ worktreePath: branch.worktreePath ?? null,
426
+ toolId:
427
+ first.toolType === "custom"
428
+ ? first.toolName ?? "custom"
429
+ : (first.toolType as LastToolUsage["toolId"]),
430
+ toolLabel:
431
+ first.toolType === "custom"
432
+ ? first.toolName ?? "Custom"
433
+ : toolLabel(first.toolType),
434
+ mode: first.mode ?? "normal",
435
+ model: null,
436
+ timestamp: first.startedAt ? Date.parse(first.startedAt) : Date.now(),
437
+ };
438
+ }, [branch?.lastToolUsage, branch?.name, branch?.worktreePath, branchSessions]);
439
+
413
440
  const handleSessionExit = (code: number) => {
414
441
  setActiveSessionId(null);
415
442
  setIsTerminalFullscreen(false);
@@ -449,6 +476,21 @@ export function BranchDetailPage() {
449
476
  {branch.worktreePath ? "Worktreeあり" : "Worktree未作成"}
450
477
  </span>
451
478
  </div>
479
+ <div className="badge-group" style={{ marginTop: "0.5rem" }}>
480
+ {latestToolUsage ? (
481
+ <>
482
+ <span className="status-badge status-badge--muted">
483
+ {renderToolUsage(latestToolUsage)}
484
+ </span>
485
+ <span className="status-badge status-badge--muted">
486
+ {formatUsageTimestamp(latestToolUsage.timestamp)} ・ worktree:{" "}
487
+ {latestToolUsage.worktreePath ?? branch.worktreePath ?? "N/A"}
488
+ </span>
489
+ </>
490
+ ) : (
491
+ <span className="status-badge status-badge--muted">Unknown</span>
492
+ )}
493
+ </div>
452
494
  <div className="page-hero__actions">
453
495
  {!canStartSession ? (
454
496
  <button
@@ -950,6 +992,32 @@ const SESSION_STATUS_LABEL: Record<
950
992
  failed: "failed",
951
993
  };
952
994
 
995
+ function renderToolUsage(usage: LastToolUsage): string {
996
+ const modeLabel =
997
+ usage.mode === "normal"
998
+ ? "New"
999
+ : usage.mode === "continue"
1000
+ ? "Continue"
1001
+ : usage.mode === "resume"
1002
+ ? "Resume"
1003
+ : null;
1004
+ const toolText = mapToolLabel(usage.toolId, usage.toolLabel);
1005
+ return [toolText, modeLabel, usage.model].filter(Boolean).join(" | ");
1006
+ }
1007
+
1008
+ function formatUsageTimestamp(value: number): string {
1009
+ return formatDate(new Date(value).toISOString());
1010
+ }
1011
+
1012
+ function mapToolLabel(toolId: string, toolLabel?: string | null): string {
1013
+ if (toolId === "claude-code") return "Claude";
1014
+ if (toolId === "codex-cli") return "Codex";
1015
+ if (toolId === "gemini-cli") return "Gemini";
1016
+ if (toolId === "qwen-cli") return "Qwen";
1017
+ if (toolLabel) return toolLabel;
1018
+ return "Custom";
1019
+ }
1020
+
953
1021
  function parseExtraArgs(value: string): string[] {
954
1022
  return value
955
1023
  .split(/\s+/)
@@ -11,6 +11,8 @@ import type {
11
11
  AIToolSession,
12
12
  StartSessionRequest,
13
13
  } from "../../../types/api.js";
14
+ import { saveSession } from "../../../config/index.js";
15
+ import { execa } from "execa";
14
16
 
15
17
  /**
16
18
  * セッション関連のルートを登録
@@ -53,6 +55,37 @@ export async function registerSessionRoutes(
53
55
  toolName,
54
56
  );
55
57
 
58
+ // 履歴を永続化(best-effort)
59
+ try {
60
+ const { stdout: repoRoot } = await execa("git", ["rev-parse", "--show-toplevel"], {
61
+ cwd: worktreePath,
62
+ });
63
+ let branchName: string | null = null;
64
+ try {
65
+ const { stdout: branchStdout } = await execa(
66
+ "git",
67
+ ["rev-parse", "--abbrev-ref", "HEAD"],
68
+ { cwd: worktreePath },
69
+ );
70
+ branchName = branchStdout.trim() || null;
71
+ } catch {
72
+ branchName = null;
73
+ }
74
+
75
+ await saveSession({
76
+ lastWorktreePath: worktreePath,
77
+ lastBranch: branchName,
78
+ lastUsedTool: toolType === "custom" ? toolName ?? "custom" : toolType,
79
+ toolLabel:
80
+ toolType === "custom" ? toolName ?? "Custom" : toolLabelFromType(toolType),
81
+ mode,
82
+ timestamp: Date.now(),
83
+ repositoryRoot: repoRoot.trim(),
84
+ });
85
+ } catch {
86
+ // ignore persistence errors
87
+ }
88
+
56
89
  reply.code(201);
57
90
  return { success: true, data: session };
58
91
  } catch (error) {
@@ -128,3 +161,9 @@ export async function registerSessionRoutes(
128
161
  }
129
162
  });
130
163
  }
164
+
165
+ function toolLabelFromType(toolType: "claude-code" | "codex-cli" | "custom") {
166
+ if (toolType === "claude-code") return "Claude";
167
+ if (toolType === "codex-cli") return "Codex";
168
+ return "Custom";
169
+ }
@@ -16,6 +16,7 @@ import {
16
16
  import { getPullRequestByBranch } from "../../../github.js";
17
17
  import { listAdditionalWorktrees } from "../../../worktree.js";
18
18
  import type { Branch, BranchSyncResult } from "../../../types/api.js";
19
+ import { getLastToolUsageMap } from "../../../config/index.js";
19
20
 
20
21
  type DivergenceStatus = { remoteAhead: number; localAhead: number };
21
22
  type DivergenceValue = NonNullable<NonNullable<Branch["divergence"]>>;
@@ -38,6 +39,7 @@ function mapPullRequestState(state: string): "open" | "merged" | "closed" {
38
39
  */
39
40
  export async function listBranches(): Promise<Branch[]> {
40
41
  const repoRoot = await getRepositoryRoot();
42
+ const lastToolUsageMap = await getLastToolUsageMap(repoRoot);
41
43
 
42
44
  // リモートブランチの最新情報を取得(失敗してもローカル情報にはフォールバック)
43
45
  try {
@@ -103,6 +105,8 @@ export async function listBranches(): Promise<Branch[]> {
103
105
  ? mapDivergence(divergenceStatus)
104
106
  : null;
105
107
 
108
+ const lastToolUsage = lastToolUsageMap.get(branchInfo.name) ?? null;
109
+
106
110
  return {
107
111
  name: branchInfo.name,
108
112
  type: branchInfo.type,
@@ -116,6 +120,7 @@ export async function listBranches(): Promise<Branch[]> {
116
120
  baseBranch: baseBranch ?? null,
117
121
  divergence,
118
122
  prInfo,
123
+ ...(lastToolUsage ? { lastToolUsage } : {}),
119
124
  };
120
125
  }),
121
126
  );
package/src/worktree.ts CHANGED
@@ -98,7 +98,13 @@ export interface WorktreeInfo {
98
98
 
99
99
  async function listWorktrees(): Promise<WorktreeInfo[]> {
100
100
  try {
101
- const { stdout } = await execa("git", ["worktree", "list", "--porcelain"]);
101
+ const { getRepositoryRoot } = await import("./git.js");
102
+ const repoRoot = await getRepositoryRoot();
103
+ const { stdout } = await execa(
104
+ "git",
105
+ ["worktree", "list", "--porcelain"],
106
+ { cwd: repoRoot },
107
+ );
102
108
  const worktrees: WorktreeInfo[] = [];
103
109
  const lines = stdout.split("\n");
104
110