@akiojin/gwt 2.7.4 → 2.8.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.
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js +5 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
- package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
- package/dist/cli/ui/hooks/useGitData.js +16 -4
- package/dist/cli/ui/hooks/useGitData.js.map +1 -1
- package/dist/cli/ui/types.d.ts +4 -0
- package/dist/cli/ui/types.d.ts.map +1 -1
- package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
- package/dist/cli/ui/utils/branchFormatter.js +47 -0
- package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
- package/dist/client/assets/{index-DxHGLTNq.js → index-CNWntAlF.js} +15 -15
- package/dist/client/index.html +1 -1
- package/dist/config/index.d.ts +17 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +66 -1
- package/dist/config/index.js.map +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +114 -30
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/types/api.d.ts +11 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/web/client/src/pages/BranchDetailPage.d.ts.map +1 -1
- package/dist/web/client/src/pages/BranchDetailPage.js +55 -0
- package/dist/web/client/src/pages/BranchDetailPage.js.map +1 -1
- package/dist/web/server/routes/sessions.d.ts.map +1 -1
- package/dist/web/server/routes/sessions.js +35 -0
- package/dist/web/server/routes/sessions.js.map +1 -1
- package/dist/web/server/services/branches.d.ts.map +1 -1
- package/dist/web/server/services/branches.js +4 -0
- package/dist/web/server/services/branches.js.map +1 -1
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +3 -1
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +53 -0
- package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +37 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
- package/src/cli/ui/hooks/useGitData.ts +15 -4
- package/src/cli/ui/types.ts +5 -0
- package/src/cli/ui/utils/branchFormatter.ts +47 -0
- package/src/config/index.ts +87 -1
- package/src/git.ts +114 -30
- package/src/index.ts +3 -0
- package/src/types/api.ts +12 -0
- package/src/web/client/src/pages/BranchDetailPage.tsx +69 -1
- package/src/web/server/routes/sessions.ts +39 -0
- package/src/web/server/services/branches.ts +5 -0
- package/src/worktree.ts +7 -1
|
@@ -279,7 +279,12 @@ export function BranchListScreen({
|
|
|
279
279
|
// Use a small safety margin to avoid terminal-dependent wrapping
|
|
280
280
|
const columns = Math.max(20, context.columns - 1);
|
|
281
281
|
const arrow = isSelected ? ">" : " ";
|
|
282
|
-
const
|
|
282
|
+
const commitText = formatLatestCommit(item.latestCommitTimestamp);
|
|
283
|
+
const infoText =
|
|
284
|
+
item.lastToolUsage && item.lastToolUsageLabel
|
|
285
|
+
? item.lastToolUsageLabel
|
|
286
|
+
: `${chalk.gray("Unknown")}${commitText !== "---" ? ` | ${commitText}` : ""}`;
|
|
287
|
+
const timestampText = infoText;
|
|
283
288
|
const timestampWidth = stringWidth(timestampText);
|
|
284
289
|
|
|
285
290
|
const indicatorInfo = cleanupUI?.indicators?.[item.name];
|
|
@@ -9,6 +9,7 @@ import { listAdditionalWorktrees } from "../../../worktree.js";
|
|
|
9
9
|
import { getPullRequestByBranch } from "../../../github.js";
|
|
10
10
|
import type { BranchInfo, WorktreeInfo } from "../types.js";
|
|
11
11
|
import type { WorktreeInfo as GitWorktreeInfo } from "../../../worktree.js";
|
|
12
|
+
import { getLastToolUsageMap } from "../../../config/index.js";
|
|
12
13
|
|
|
13
14
|
export interface UseGitDataOptions {
|
|
14
15
|
enableAutoRefresh?: boolean;
|
|
@@ -52,10 +53,17 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const branchesData = await getAllBranches();
|
|
57
|
+
let worktreesData: GitWorktreeInfo[] = [];
|
|
58
|
+
try {
|
|
59
|
+
worktreesData = await listAdditionalWorktrees();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (process.env.DEBUG) {
|
|
62
|
+
console.error("Failed to list additional worktrees:", err);
|
|
63
|
+
}
|
|
64
|
+
worktreesData = [];
|
|
65
|
+
}
|
|
66
|
+
const lastToolUsageMap = await getLastToolUsageMap(repoRoot);
|
|
59
67
|
|
|
60
68
|
// Store worktrees separately
|
|
61
69
|
setWorktrees(worktreesData);
|
|
@@ -117,6 +125,9 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
117
125
|
},
|
|
118
126
|
}
|
|
119
127
|
: {}),
|
|
128
|
+
...(lastToolUsageMap.get(branch.name)
|
|
129
|
+
? { lastToolUsage: lastToolUsageMap.get(branch.name) ?? null }
|
|
130
|
+
: {}),
|
|
120
131
|
};
|
|
121
132
|
}),
|
|
122
133
|
);
|
package/src/cli/ui/types.ts
CHANGED
|
@@ -5,8 +5,11 @@ export interface WorktreeInfo {
|
|
|
5
5
|
isAccessible?: boolean;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
import type { LastToolUsage } from "../../types/api.js";
|
|
9
|
+
|
|
8
10
|
export type AITool = string;
|
|
9
11
|
export type InferenceLevel = "low" | "medium" | "high" | "xhigh";
|
|
12
|
+
export type { LastToolUsage } from "../../types/api.js";
|
|
10
13
|
|
|
11
14
|
export interface ModelOption {
|
|
12
15
|
id: string;
|
|
@@ -35,6 +38,7 @@ export interface BranchInfo {
|
|
|
35
38
|
openPR?: { number: number; title: string };
|
|
36
39
|
mergedPR?: { number: number; mergedAt: string };
|
|
37
40
|
latestCommitTimestamp?: number;
|
|
41
|
+
lastToolUsage?: LastToolUsage | null;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export interface BranchChoice {
|
|
@@ -204,6 +208,7 @@ export interface BranchItem extends BranchInfo {
|
|
|
204
208
|
hasChanges: boolean;
|
|
205
209
|
label: string;
|
|
206
210
|
value: string;
|
|
211
|
+
lastToolUsageLabel?: string | null;
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
/**
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
WorktreeInfo,
|
|
7
7
|
} from "../types.js";
|
|
8
8
|
import stringWidth from "string-width";
|
|
9
|
+
import chalk from "chalk";
|
|
9
10
|
|
|
10
11
|
// Icon mappings
|
|
11
12
|
const branchIcons: Record<BranchType, string> = {
|
|
@@ -69,6 +70,51 @@ export interface FormatOptions {
|
|
|
69
70
|
hasChanges?: boolean;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function mapToolLabel(toolId: string, toolLabel?: string): string {
|
|
74
|
+
if (toolId === "claude-code") return "Claude";
|
|
75
|
+
if (toolId === "codex-cli") return "Codex";
|
|
76
|
+
if (toolId === "gemini-cli") return "Gemini";
|
|
77
|
+
if (toolId === "qwen-cli") return "Qwen";
|
|
78
|
+
if (toolLabel) return toolLabel;
|
|
79
|
+
return "Custom";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mapModeLabel(
|
|
83
|
+
mode?: "normal" | "continue" | "resume" | null,
|
|
84
|
+
): string | null {
|
|
85
|
+
if (mode === "normal") return "New";
|
|
86
|
+
if (mode === "continue") return "Continue";
|
|
87
|
+
if (mode === "resume") return "Resume";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatTimestamp(ts: number): string {
|
|
92
|
+
const date = new Date(ts);
|
|
93
|
+
const year = date.getFullYear();
|
|
94
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
95
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
96
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
97
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
98
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildLastToolUsageLabel(
|
|
102
|
+
usage?: BranchInfo["lastToolUsage"] | null,
|
|
103
|
+
): string | null {
|
|
104
|
+
if (!usage) return null;
|
|
105
|
+
const toolText = mapToolLabel(usage.toolId, usage.toolLabel);
|
|
106
|
+
const modeText = mapModeLabel(usage.mode);
|
|
107
|
+
const timestamp = usage.timestamp ? formatTimestamp(usage.timestamp) : null;
|
|
108
|
+
const parts = [toolText];
|
|
109
|
+
if (modeText) {
|
|
110
|
+
parts.push(modeText);
|
|
111
|
+
}
|
|
112
|
+
if (timestamp) {
|
|
113
|
+
parts.push(timestamp);
|
|
114
|
+
}
|
|
115
|
+
return parts.join(" | ");
|
|
116
|
+
}
|
|
117
|
+
|
|
72
118
|
/**
|
|
73
119
|
* Converts BranchInfo to BranchItem with display properties
|
|
74
120
|
*/
|
|
@@ -171,6 +217,7 @@ export function formatBranchItem(
|
|
|
171
217
|
hasChanges,
|
|
172
218
|
label,
|
|
173
219
|
value: branch.name,
|
|
220
|
+
lastToolUsageLabel: buildLastToolUsageLabel(branch.lastToolUsage),
|
|
174
221
|
};
|
|
175
222
|
}
|
|
176
223
|
|
package/src/config/index.ts
CHANGED
|
@@ -16,6 +16,20 @@ export interface SessionData {
|
|
|
16
16
|
lastUsedTool?: string;
|
|
17
17
|
timestamp: number;
|
|
18
18
|
repositoryRoot: string;
|
|
19
|
+
mode?: "normal" | "continue" | "resume";
|
|
20
|
+
model?: string | null;
|
|
21
|
+
toolLabel?: string | null;
|
|
22
|
+
history?: ToolSessionEntry[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolSessionEntry {
|
|
26
|
+
branch: string;
|
|
27
|
+
worktreePath: string | null;
|
|
28
|
+
toolId: string;
|
|
29
|
+
toolLabel: string;
|
|
30
|
+
mode?: "normal" | "continue" | "resume" | null;
|
|
31
|
+
model?: string | null;
|
|
32
|
+
timestamp: number;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
const DEFAULT_CONFIG: AppConfig = {
|
|
@@ -104,7 +118,38 @@ export async function saveSession(sessionData: SessionData): Promise<void> {
|
|
|
104
118
|
// ディレクトリを作成
|
|
105
119
|
await mkdir(sessionDir, { recursive: true });
|
|
106
120
|
|
|
107
|
-
|
|
121
|
+
// 既存履歴を読み込み(後方互換のため失敗は無視)
|
|
122
|
+
let existingHistory: ToolSessionEntry[] = [];
|
|
123
|
+
try {
|
|
124
|
+
const currentContent = await readFile(sessionPath, "utf-8");
|
|
125
|
+
const parsed = JSON.parse(currentContent) as SessionData;
|
|
126
|
+
if (Array.isArray(parsed.history)) {
|
|
127
|
+
existingHistory = parsed.history;
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 新しい履歴エントリを追加(branch/worktree/toolが揃っている場合のみ)
|
|
134
|
+
if (sessionData.lastBranch && sessionData.lastWorktreePath) {
|
|
135
|
+
const entry: ToolSessionEntry = {
|
|
136
|
+
branch: sessionData.lastBranch,
|
|
137
|
+
worktreePath: sessionData.lastWorktreePath,
|
|
138
|
+
toolId: sessionData.lastUsedTool ?? "unknown",
|
|
139
|
+
toolLabel: sessionData.toolLabel ?? sessionData.lastUsedTool ?? "Custom",
|
|
140
|
+
mode: sessionData.mode ?? null,
|
|
141
|
+
model: sessionData.model ?? null,
|
|
142
|
+
timestamp: sessionData.timestamp,
|
|
143
|
+
};
|
|
144
|
+
existingHistory = [...existingHistory, entry].slice(-100); // keep latest 100
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const payload: SessionData = {
|
|
148
|
+
...sessionData,
|
|
149
|
+
history: existingHistory,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await writeFile(sessionPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
108
153
|
} catch (error) {
|
|
109
154
|
// セッション保存の失敗は致命的ではないため、エラーをログに出力するのみ
|
|
110
155
|
if (process.env.DEBUG_SESSION) {
|
|
@@ -192,3 +237,44 @@ export async function getAllSessions(): Promise<SessionData[]> {
|
|
|
192
237
|
return [];
|
|
193
238
|
}
|
|
194
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 各ブランチの最新ツール利用履歴を取得
|
|
243
|
+
*/
|
|
244
|
+
export async function getLastToolUsageMap(
|
|
245
|
+
repositoryRoot: string,
|
|
246
|
+
): Promise<Map<string, ToolSessionEntry>> {
|
|
247
|
+
const map = new Map<string, ToolSessionEntry>();
|
|
248
|
+
try {
|
|
249
|
+
const sessionPath = getSessionFilePath(repositoryRoot);
|
|
250
|
+
const content = await readFile(sessionPath, "utf-8");
|
|
251
|
+
const parsed = JSON.parse(content) as SessionData;
|
|
252
|
+
|
|
253
|
+
const history: ToolSessionEntry[] = Array.isArray(parsed.history)
|
|
254
|
+
? parsed.history
|
|
255
|
+
: [];
|
|
256
|
+
|
|
257
|
+
// 後方互換: historyが無い場合はlastUsedToolを1件扱い
|
|
258
|
+
if (!history.length && parsed.lastBranch && parsed.lastWorktreePath) {
|
|
259
|
+
history.push({
|
|
260
|
+
branch: parsed.lastBranch,
|
|
261
|
+
worktreePath: parsed.lastWorktreePath,
|
|
262
|
+
toolId: parsed.lastUsedTool ?? "unknown",
|
|
263
|
+
toolLabel: parsed.toolLabel ?? parsed.lastUsedTool ?? "Custom",
|
|
264
|
+
mode: parsed.mode ?? null,
|
|
265
|
+
model: parsed.model ?? null,
|
|
266
|
+
timestamp: parsed.timestamp ?? Date.now(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const entry of history) {
|
|
271
|
+
const existing = map.get(entry.branch);
|
|
272
|
+
if (!existing || existing.timestamp < entry.timestamp) {
|
|
273
|
+
map.set(entry.branch, entry);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// セッションファイルが無い/壊れている場合は空のMapを返す
|
|
278
|
+
}
|
|
279
|
+
return map;
|
|
280
|
+
}
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
139
|
-
"branch",
|
|
140
|
-
|
|
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 (
|
|
160
|
-
throw new GitError("Failed to get local branches",
|
|
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
|
|
168
|
-
"branch",
|
|
169
|
-
"
|
|
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 (
|
|
191
|
-
throw new GitError("Failed to get remote branches",
|
|
274
|
+
} catch (primaryError) {
|
|
275
|
+
throw new GitError("Failed to get remote branches", primaryError);
|
|
192
276
|
}
|
|
193
277
|
}
|
|
194
278
|
|
package/src/index.ts
CHANGED
|
@@ -670,6 +670,9 @@ export async function handleAIToolWorkflow(
|
|
|
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 {
|
|
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
|
);
|