@akiojin/gwt 4.6.1 → 4.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.
Files changed (83) hide show
  1. package/dist/claude.d.ts.map +1 -1
  2. package/dist/claude.js +6 -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 +103 -2
  6. package/dist/cli/ui/components/App.js.map +1 -1
  7. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +1 -0
  8. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  9. package/dist/cli/ui/components/screens/BranchListScreen.js +11 -8
  10. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  11. package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts +10 -0
  12. package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts.map +1 -0
  13. package/dist/cli/ui/components/screens/LogDatePickerScreen.js +44 -0
  14. package/dist/cli/ui/components/screens/LogDatePickerScreen.js.map +1 -0
  15. package/dist/cli/ui/components/screens/LogDetailScreen.d.ts +14 -0
  16. package/dist/cli/ui/components/screens/LogDetailScreen.d.ts.map +1 -0
  17. package/dist/cli/ui/components/screens/LogDetailScreen.js +34 -0
  18. package/dist/cli/ui/components/screens/LogDetailScreen.js.map +1 -0
  19. package/dist/cli/ui/components/screens/LogListScreen.d.ts +19 -0
  20. package/dist/cli/ui/components/screens/LogListScreen.d.ts.map +1 -0
  21. package/dist/cli/ui/components/screens/LogListScreen.js +107 -0
  22. package/dist/cli/ui/components/screens/LogListScreen.js.map +1 -0
  23. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  24. package/dist/cli/ui/hooks/useGitData.js +10 -3
  25. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  26. package/dist/cli/ui/types.d.ts +1 -1
  27. package/dist/cli/ui/types.d.ts.map +1 -1
  28. package/dist/cli/ui/utils/branchFormatter.d.ts +5 -0
  29. package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
  30. package/dist/cli/ui/utils/branchFormatter.js +18 -5
  31. package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
  32. package/dist/cli/ui/utils/clipboard.d.ts +7 -0
  33. package/dist/cli/ui/utils/clipboard.d.ts.map +1 -0
  34. package/dist/cli/ui/utils/clipboard.js +21 -0
  35. package/dist/cli/ui/utils/clipboard.js.map +1 -0
  36. package/dist/codex.d.ts.map +1 -1
  37. package/dist/codex.js +0 -1
  38. package/dist/codex.js.map +1 -1
  39. package/dist/gemini.d.ts.map +1 -1
  40. package/dist/gemini.js +6 -3
  41. package/dist/gemini.js.map +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +104 -81
  44. package/dist/index.js.map +1 -1
  45. package/dist/logging/formatter.d.ts +15 -0
  46. package/dist/logging/formatter.d.ts.map +1 -0
  47. package/dist/logging/formatter.js +81 -0
  48. package/dist/logging/formatter.js.map +1 -0
  49. package/dist/logging/reader.d.ts +12 -0
  50. package/dist/logging/reader.d.ts.map +1 -0
  51. package/dist/logging/reader.js +63 -0
  52. package/dist/logging/reader.js.map +1 -0
  53. package/dist/worktree.d.ts.map +1 -1
  54. package/dist/worktree.js +57 -0
  55. package/dist/worktree.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/claude.ts +7 -2
  58. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +8 -4
  59. package/src/cli/ui/__tests__/components/App.test.tsx +65 -3
  60. package/src/cli/ui/__tests__/components/common/Select.test.tsx +17 -11
  61. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +28 -2
  62. package/src/cli/ui/__tests__/components/screens/LogDetailScreen.test.tsx +57 -0
  63. package/src/cli/ui/__tests__/components/screens/LogListScreen.test.tsx +102 -0
  64. package/src/cli/ui/__tests__/hooks/useGitData.test.ts +197 -0
  65. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +84 -13
  66. package/src/cli/ui/__tests__/integration/navigation.test.tsx +57 -37
  67. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +105 -0
  68. package/src/cli/ui/__tests__/utils/clipboard.test.ts +65 -0
  69. package/src/cli/ui/components/App.tsx +178 -1
  70. package/src/cli/ui/components/screens/BranchListScreen.tsx +11 -6
  71. package/src/cli/ui/components/screens/LogDatePickerScreen.tsx +83 -0
  72. package/src/cli/ui/components/screens/LogDetailScreen.tsx +67 -0
  73. package/src/cli/ui/components/screens/LogListScreen.tsx +192 -0
  74. package/src/cli/ui/hooks/useGitData.ts +12 -3
  75. package/src/cli/ui/types.ts +3 -0
  76. package/src/cli/ui/utils/branchFormatter.ts +19 -5
  77. package/src/cli/ui/utils/clipboard.ts +31 -0
  78. package/src/codex.ts +0 -1
  79. package/src/gemini.ts +7 -3
  80. package/src/index.ts +147 -123
  81. package/src/logging/formatter.ts +106 -0
  82. package/src/logging/reader.ts +76 -0
  83. package/src/worktree.ts +77 -0
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import {
3
3
  getAllBranches,
4
4
  hasUnpushedCommitsInRepo,
@@ -80,7 +80,15 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
80
80
  const [error, setError] = useState<Error | null>(null);
81
81
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
82
82
 
83
- const loadData = useCallback(async () => {
83
+ // キャッシュ機構: 初回ロード完了を追跡(useRef で再レンダリングを防ぐ)
84
+ const hasLoadedOnceRef = useRef(false);
85
+
86
+ const loadData = useCallback(async (forceRefresh = false) => {
87
+ // キャッシュがあり、強制リフレッシュでなければスキップ
88
+ if (hasLoadedOnceRef.current && !forceRefresh) {
89
+ return;
90
+ }
91
+
84
92
  setLoading(true);
85
93
  setError(null);
86
94
 
@@ -295,6 +303,7 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
295
303
 
296
304
  setBranches(enrichedBranches);
297
305
  setLastUpdated(new Date());
306
+ hasLoadedOnceRef.current = true; // 初回ロード完了をマーク
298
307
  } catch (err) {
299
308
  setError(err instanceof Error ? err : new Error(String(err)));
300
309
  setBranches([]);
@@ -305,7 +314,7 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
305
314
  }, []);
306
315
 
307
316
  const refresh = useCallback(() => {
308
- loadData();
317
+ loadData(true); // forceRefresh = true で強制的にデータを再取得
309
318
  }, [loadData]);
310
319
 
311
320
  useEffect(() => {
@@ -188,6 +188,9 @@ export interface GitHubPRResponse {
188
188
  */
189
189
  export type ScreenType =
190
190
  | "branch-list"
191
+ | "log-list"
192
+ | "log-detail"
193
+ | "log-date-picker"
191
194
  | "branch-creator"
192
195
  | "branch-action-selector"
193
196
  | "branch-quick-start"
@@ -125,6 +125,19 @@ function buildLastToolUsageLabel(
125
125
  return parts.join(" | ");
126
126
  }
127
127
 
128
+ /**
129
+ * Calculate the latest activity timestamp for a branch.
130
+ * Returns the maximum of git commit timestamp and tool usage timestamp (in seconds).
131
+ */
132
+ export function getLatestActivityTimestamp(branch: BranchInfo): number {
133
+ const gitTimestampSec = branch.latestCommitTimestamp ?? 0;
134
+ // lastToolUsage.timestamp is in milliseconds, convert to seconds
135
+ const toolTimestampSec = branch.lastToolUsage?.timestamp
136
+ ? Math.floor(branch.lastToolUsage.timestamp / 1000)
137
+ : 0;
138
+ return Math.max(gitTimestampSec, toolTimestampSec);
139
+ }
140
+
128
141
  /**
129
142
  * Converts BranchInfo to BranchItem with display properties
130
143
  */
@@ -357,11 +370,12 @@ function sortBranches(
357
370
  if (aHasWorktree && !bHasWorktree) return -1;
358
371
  if (!aHasWorktree && bHasWorktree) return 1;
359
372
 
360
- // 5. Prioritize most recent commit within same worktree status
361
- const aCommit = a.latestCommitTimestamp ?? 0;
362
- const bCommit = b.latestCommitTimestamp ?? 0;
363
- if (aCommit !== bCommit) {
364
- return bCommit - aCommit;
373
+ // 5. Prioritize most recent activity within same worktree status
374
+ // (max of git commit timestamp and tool usage timestamp)
375
+ const aLatest = getLatestActivityTimestamp(a);
376
+ const bLatest = getLatestActivityTimestamp(b);
377
+ if (aLatest !== bLatest) {
378
+ return bLatest - aLatest;
365
379
  }
366
380
 
367
381
  // 6. Local branches are prioritized over remote-only
@@ -0,0 +1,31 @@
1
+ import { execa } from "execa";
2
+
3
+ export interface ClipboardOptions {
4
+ platform?: NodeJS.Platform;
5
+ execa?: typeof execa;
6
+ }
7
+
8
+ export async function copyToClipboard(
9
+ text: string,
10
+ options: ClipboardOptions = {},
11
+ ): Promise<void> {
12
+ const runner = options.execa ?? execa;
13
+ const platform = options.platform ?? process.platform;
14
+
15
+ if (platform === "win32") {
16
+ await runner("cmd", ["/c", "clip"], { input: text, windowsHide: true });
17
+ return;
18
+ }
19
+
20
+ if (platform === "darwin") {
21
+ await runner("pbcopy", [], { input: text });
22
+ return;
23
+ }
24
+
25
+ try {
26
+ await runner("xclip", ["-selection", "clipboard"], { input: text });
27
+ return;
28
+ } catch {
29
+ await runner("xsel", ["--clipboard", "--input"], { input: text });
30
+ }
31
+ }
package/src/codex.ts CHANGED
@@ -196,7 +196,6 @@ export async function launchCodexCLI(
196
196
 
197
197
  const child = execa("bunx", [CODEX_CLI_PACKAGE, ...args], {
198
198
  cwd: worktreePath,
199
- shell: true,
200
199
  stdin: childStdio.stdin,
201
200
  stdout: childStdio.stdout,
202
201
  stderr: childStdio.stderr,
package/src/gemini.ts CHANGED
@@ -175,7 +175,6 @@ export async function launchGeminiCLI(
175
175
  const run = async (cmd: string, args: string[]) => {
176
176
  const child = execa(cmd, args, {
177
177
  cwd: worktreePath,
178
- shell: true,
179
178
  stdin: childStdio.stdin,
180
179
  stdout: childStdio.stdout,
181
180
  stderr: childStdio.stderr,
@@ -201,7 +200,12 @@ export async function launchGeminiCLI(
201
200
  );
202
201
  console.log(chalk.yellow(" npm install -g @google/gemini-cli"));
203
202
  console.log("");
204
- await new Promise((resolve) => setTimeout(resolve, 2000));
203
+ const shouldSkipDelay =
204
+ typeof process !== "undefined" &&
205
+ (process.env?.NODE_ENV === "test" || Boolean(process.env?.VITEST));
206
+ if (!shouldSkipDelay) {
207
+ await new Promise((resolve) => setTimeout(resolve, 2000));
208
+ }
205
209
  return await run("bunx", [GEMINI_CLI_PACKAGE, ...runArgs]);
206
210
  };
207
211
 
@@ -323,7 +327,7 @@ export async function launchGeminiCLI(
323
327
  */
324
328
  export async function isGeminiCLIAvailable(): Promise<boolean> {
325
329
  try {
326
- await execa("bunx", [GEMINI_CLI_PACKAGE, "--version"], { shell: true });
330
+ await execa("bunx", [GEMINI_CLI_PACKAGE, "--version"]);
327
331
  return true;
328
332
  } catch (error: unknown) {
329
333
  const err = error as NodeJS.ErrnoException;
package/src/index.ts CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  hasUnpushedCommits,
14
14
  getUncommittedChangesCount,
15
15
  getUnpushedCommitsCount,
16
- pushBranchToRemote,
17
16
  GitError,
18
17
  } from "./git.js";
19
18
  import { launchClaudeCode } from "./claude.js";
@@ -57,7 +56,7 @@ import {
57
56
  DependencyInstallError,
58
57
  type DependencyInstallResult,
59
58
  } from "./services/dependency-installer.js";
60
- import { confirmYesNo, waitForEnter } from "./utils/prompt.js";
59
+ import { waitForEnter } from "./utils/prompt.js";
61
60
 
62
61
  const ERROR_PROMPT = chalk.yellow(
63
62
  "Review the error details, then press Enter to continue.",
@@ -435,7 +434,7 @@ export async function handleAIToolWorkflow(
435
434
  switch (dependencyStatus.reason) {
436
435
  case "missing-lockfile":
437
436
  warningMessage =
438
- "Skipping automatic install because no lockfiles (bun.lock / pnpm-lock.yaml / package-lock.json) or package.json were found. Run the appropriate package-manager install command manually if needed.";
437
+ "Skipping automatic install because no lockfiles (bun.lock / pnpm-lock.yaml / package-lock.json) or package.json could be found. Run the appropriate package-manager install command manually if needed.";
439
438
  break;
440
439
  case "missing-binary":
441
440
  warningMessage = `Package manager '${dependencyStatus.manager ?? "unknown"}' is not available in this environment; skipping automatic install.`;
@@ -554,23 +553,21 @@ export async function handleAIToolWorkflow(
554
553
  throw new Error(`Tool not found: ${tool}`);
555
554
  }
556
555
 
557
- // Save selection immediately so "last tool" is reflected even if the tool
558
- // is interrupted or killed mid-run (e.g., Ctrl+C).
559
- await saveSession(
560
- {
561
- lastWorktreePath: worktreePath,
562
- lastBranch: branch,
563
- lastUsedTool: tool,
564
- toolLabel: toolConfig.displayName ?? tool,
565
- mode,
566
- model: normalizedModel ?? null,
567
- reasoningLevel: inferenceLevel ?? null,
568
- skipPermissions: skipPermissions ?? null,
569
- timestamp: Date.now(),
570
- repositoryRoot: repoRoot,
571
- },
572
- { skipHistory: true },
573
- );
556
+ // Save selection immediately (including history) so "last tool" is reflected
557
+ // even if the tool is interrupted or killed mid-run (e.g., Ctrl+C).
558
+ // FR-042: Record timestamp to session history immediately on tool start.
559
+ await saveSession({
560
+ lastWorktreePath: worktreePath,
561
+ lastBranch: branch,
562
+ lastUsedTool: tool,
563
+ toolLabel: toolConfig.displayName ?? tool,
564
+ mode,
565
+ model: normalizedModel ?? null,
566
+ reasoningLevel: inferenceLevel ?? null,
567
+ skipPermissions: skipPermissions ?? null,
568
+ timestamp: Date.now(),
569
+ repositoryRoot: repoRoot,
570
+ });
574
571
 
575
572
  // Lookup saved session ID for Continue (auto attach)
576
573
  let resumeSessionId: string | null =
@@ -600,96 +597,127 @@ export async function handleAIToolWorkflow(
600
597
 
601
598
  const launchStartedAt = Date.now();
602
599
 
600
+ // FR-043: Start periodic timestamp update timer (30 seconds interval)
601
+ // This ensures the latest activity time is updated even if the tool is force-killed
602
+ const SESSION_UPDATE_INTERVAL_MS = 30_000;
603
+ const updateTimer = setInterval(async () => {
604
+ try {
605
+ await saveSession(
606
+ {
607
+ lastWorktreePath: worktreePath,
608
+ lastBranch: branch,
609
+ lastUsedTool: tool,
610
+ toolLabel: toolConfig.displayName ?? tool,
611
+ mode,
612
+ model: normalizedModel ?? null,
613
+ reasoningLevel: inferenceLevel ?? null,
614
+ skipPermissions: skipPermissions ?? null,
615
+ timestamp: Date.now(),
616
+ repositoryRoot: repoRoot,
617
+ },
618
+ { skipHistory: true }, // Don't add to history, just update timestamp
619
+ );
620
+ } catch {
621
+ // Ignore errors during periodic update
622
+ }
623
+ }, SESSION_UPDATE_INTERVAL_MS);
624
+
603
625
  // Launch selected AI tool
604
626
  // Builtin tools use their dedicated launch functions
605
627
  // Custom tools use the generic launchCustomAITool function
606
628
  let launchResult: { sessionId?: string | null } | void;
607
- if (tool === "claude-code") {
608
- const launchOptions: {
609
- mode?: "normal" | "continue" | "resume";
610
- skipPermissions?: boolean;
611
- envOverrides?: Record<string, string>;
612
- model?: string;
613
- sessionId?: string | null;
614
- chrome?: boolean;
615
- } = {
616
- mode:
617
- mode === "resume"
618
- ? "resume"
619
- : mode === "continue"
620
- ? "continue"
621
- : "normal",
622
- skipPermissions,
623
- envOverrides: sharedEnv,
624
- sessionId: resumeSessionId,
625
- chrome: true,
626
- };
627
- if (normalizedModel) {
628
- launchOptions.model = normalizedModel;
629
- }
630
- launchResult = await launchClaudeCode(worktreePath, launchOptions);
631
- } else if (tool === "codex-cli") {
632
- const launchOptions: {
633
- mode?: "normal" | "continue" | "resume";
634
- bypassApprovals?: boolean;
635
- envOverrides?: Record<string, string>;
636
- model?: string;
637
- reasoningEffort?: CodexReasoningEffort;
638
- sessionId?: string | null;
639
- } = {
640
- mode:
641
- mode === "resume"
642
- ? "resume"
643
- : mode === "continue"
644
- ? "continue"
645
- : "normal",
646
- bypassApprovals: skipPermissions,
647
- envOverrides: sharedEnv,
648
- sessionId: resumeSessionId,
649
- };
650
- if (normalizedModel) {
651
- launchOptions.model = normalizedModel;
652
- }
653
- if (inferenceLevel) {
654
- launchOptions.reasoningEffort = inferenceLevel as CodexReasoningEffort;
655
- }
656
- launchResult = await launchCodexCLI(worktreePath, launchOptions);
657
- } else if (tool === "gemini-cli") {
658
- const launchOptions: {
659
- mode?: "normal" | "continue" | "resume";
660
- skipPermissions?: boolean;
661
- envOverrides?: Record<string, string>;
662
- model?: string;
663
- sessionId?: string | null;
664
- } = {
665
- mode:
666
- mode === "resume"
667
- ? "resume"
668
- : mode === "continue"
669
- ? "continue"
670
- : "normal",
671
- skipPermissions,
672
- envOverrides: sharedEnv,
673
- sessionId: resumeSessionId,
674
- };
675
- if (normalizedModel) {
676
- launchOptions.model = normalizedModel;
629
+ try {
630
+ if (tool === "claude-code") {
631
+ const launchOptions: {
632
+ mode?: "normal" | "continue" | "resume";
633
+ skipPermissions?: boolean;
634
+ envOverrides?: Record<string, string>;
635
+ model?: string;
636
+ sessionId?: string | null;
637
+ chrome?: boolean;
638
+ } = {
639
+ mode:
640
+ mode === "resume"
641
+ ? "resume"
642
+ : mode === "continue"
643
+ ? "continue"
644
+ : "normal",
645
+ skipPermissions,
646
+ envOverrides: sharedEnv,
647
+ sessionId: resumeSessionId,
648
+ chrome: true,
649
+ };
650
+ if (normalizedModel) {
651
+ launchOptions.model = normalizedModel;
652
+ }
653
+ launchResult = await launchClaudeCode(worktreePath, launchOptions);
654
+ } else if (tool === "codex-cli") {
655
+ const launchOptions: {
656
+ mode?: "normal" | "continue" | "resume";
657
+ bypassApprovals?: boolean;
658
+ envOverrides?: Record<string, string>;
659
+ model?: string;
660
+ reasoningEffort?: CodexReasoningEffort;
661
+ sessionId?: string | null;
662
+ } = {
663
+ mode:
664
+ mode === "resume"
665
+ ? "resume"
666
+ : mode === "continue"
667
+ ? "continue"
668
+ : "normal",
669
+ bypassApprovals: skipPermissions,
670
+ envOverrides: sharedEnv,
671
+ sessionId: resumeSessionId,
672
+ };
673
+ if (normalizedModel) {
674
+ launchOptions.model = normalizedModel;
675
+ }
676
+ if (inferenceLevel) {
677
+ launchOptions.reasoningEffort =
678
+ inferenceLevel as CodexReasoningEffort;
679
+ }
680
+ launchResult = await launchCodexCLI(worktreePath, launchOptions);
681
+ } else if (tool === "gemini-cli") {
682
+ const launchOptions: {
683
+ mode?: "normal" | "continue" | "resume";
684
+ skipPermissions?: boolean;
685
+ envOverrides?: Record<string, string>;
686
+ model?: string;
687
+ sessionId?: string | null;
688
+ } = {
689
+ mode:
690
+ mode === "resume"
691
+ ? "resume"
692
+ : mode === "continue"
693
+ ? "continue"
694
+ : "normal",
695
+ skipPermissions,
696
+ envOverrides: sharedEnv,
697
+ sessionId: resumeSessionId,
698
+ };
699
+ if (normalizedModel) {
700
+ launchOptions.model = normalizedModel;
701
+ }
702
+ launchResult = await launchGeminiCLI(worktreePath, launchOptions);
703
+ } else {
704
+ // Custom tool
705
+ printInfo(`Launching custom tool: ${toolConfig.displayName}`);
706
+ launchResult = await launchCustomAITool(toolConfig, {
707
+ mode:
708
+ mode === "resume"
709
+ ? "resume"
710
+ : mode === "continue"
711
+ ? "continue"
712
+ : "normal",
713
+ skipPermissions,
714
+ cwd: worktreePath,
715
+ sharedEnv,
716
+ });
677
717
  }
678
- launchResult = await launchGeminiCLI(worktreePath, launchOptions);
679
- } else {
680
- // Custom tool
681
- printInfo(`Launching custom tool: ${toolConfig.displayName}`);
682
- launchResult = await launchCustomAITool(toolConfig, {
683
- mode:
684
- mode === "resume"
685
- ? "resume"
686
- : mode === "continue"
687
- ? "continue"
688
- : "normal",
689
- skipPermissions,
690
- cwd: worktreePath,
691
- sharedEnv,
692
- });
718
+ } finally {
719
+ // FR-043: Clear the periodic timestamp update timer
720
+ clearInterval(updateTimer);
693
721
  }
694
722
 
695
723
  // Persist session with captured session ID (if any)
@@ -777,8 +805,12 @@ export async function handleAIToolWorkflow(
777
805
  if (hasUncommitted) {
778
806
  const uncommittedCount = await getUncommittedChangesCount(worktreePath);
779
807
  const countLabel =
780
- uncommittedCount > 0 ? ` (${uncommittedCount}件)` : "";
781
- printWarning(`未コミットの変更があります${countLabel}。`);
808
+ uncommittedCount > 0
809
+ ? ` (${uncommittedCount} ${
810
+ uncommittedCount === 1 ? "change" : "changes"
811
+ })`
812
+ : "";
813
+ printWarning(`Uncommitted changes detected${countLabel}.`);
782
814
  }
783
815
 
784
816
  if (hasUnpushed) {
@@ -786,27 +818,19 @@ export async function handleAIToolWorkflow(
786
818
  worktreePath,
787
819
  branch,
788
820
  );
789
- const countLabel = unpushedCount > 0 ? ` (${unpushedCount}件)` : "";
790
- const shouldPush = await confirmYesNo(
791
- `未プッシュのコミットがあります${countLabel}。プッシュしますか?`,
792
- { defaultValue: false },
793
- );
794
- if (shouldPush) {
795
- printInfo(`Pushing origin/${branch}...`);
796
- try {
797
- await pushBranchToRemote(worktreePath, branch);
798
- printInfo(`Push completed for ${branch}.`);
799
- } catch (error) {
800
- const details =
801
- error instanceof Error ? error.message : String(error);
802
- printWarning(`Push failed for ${branch}: ${details}`);
803
- }
804
- }
821
+ const countLabel =
822
+ unpushedCount > 0
823
+ ? ` (${unpushedCount} ${
824
+ unpushedCount === 1 ? "commit" : "commits"
825
+ })`
826
+ : "";
827
+ printWarning(`Unpushed commits detected${countLabel}.`);
805
828
  }
806
829
  } catch (error) {
807
830
  const details = error instanceof Error ? error.message : String(error);
808
831
  printWarning(`Failed to check git status after session: ${details}`);
809
832
  }
833
+
810
834
  // Small buffer before returning to branch list to avoid abrupt screen swap
811
835
  await new Promise((resolve) => setTimeout(resolve, POST_SESSION_DELAY_MS));
812
836
  printInfo("Session completed successfully. Returning to main menu...");
@@ -0,0 +1,106 @@
1
+ export interface FormattedLogEntry {
2
+ id: string;
3
+ raw: Record<string, unknown>;
4
+ timestamp: number | null;
5
+ timeLabel: string;
6
+ levelLabel: string;
7
+ category: string;
8
+ message: string;
9
+ summary: string;
10
+ json: string;
11
+ }
12
+
13
+ const LEVEL_LABELS: Record<number, string> = {
14
+ 10: "TRACE",
15
+ 20: "DEBUG",
16
+ 30: "INFO",
17
+ 40: "WARN",
18
+ 50: "ERROR",
19
+ 60: "FATAL",
20
+ };
21
+
22
+ const formatTimeLabel = (
23
+ value: unknown,
24
+ ): { label: string; timestamp: number | null } => {
25
+ if (typeof value === "string" || typeof value === "number") {
26
+ const date = new Date(value);
27
+ if (!Number.isNaN(date.getTime())) {
28
+ const hours = String(date.getHours()).padStart(2, "0");
29
+ const minutes = String(date.getMinutes()).padStart(2, "0");
30
+ const seconds = String(date.getSeconds()).padStart(2, "0");
31
+ return {
32
+ label: `${hours}:${minutes}:${seconds}`,
33
+ timestamp: date.getTime(),
34
+ };
35
+ }
36
+ }
37
+
38
+ return { label: "--:--:--", timestamp: null };
39
+ };
40
+
41
+ const formatLevelLabel = (value: unknown): string => {
42
+ if (typeof value === "number") {
43
+ return LEVEL_LABELS[value] ?? `LEVEL-${value}`;
44
+ }
45
+ if (typeof value === "string") {
46
+ return value.toUpperCase();
47
+ }
48
+ return "UNKNOWN";
49
+ };
50
+
51
+ const resolveMessage = (entry: Record<string, unknown>): string => {
52
+ if (typeof entry.msg === "string") {
53
+ return entry.msg;
54
+ }
55
+ if (typeof entry.message === "string") {
56
+ return entry.message;
57
+ }
58
+ if (entry.msg !== undefined) {
59
+ return String(entry.msg);
60
+ }
61
+ return "";
62
+ };
63
+
64
+ export function parseLogLines(
65
+ lines: string[],
66
+ options: { limit?: number } = {},
67
+ ): FormattedLogEntry[] {
68
+ const entries: FormattedLogEntry[] = [];
69
+
70
+ lines.forEach((line, index) => {
71
+ if (!line.trim()) return;
72
+ try {
73
+ const parsed = JSON.parse(line) as Record<string, unknown>;
74
+ const { label: timeLabel, timestamp } = formatTimeLabel(parsed.time);
75
+ const levelLabel = formatLevelLabel(parsed.level);
76
+ const category =
77
+ typeof parsed.category === "string" ? parsed.category : "unknown";
78
+ const message = resolveMessage(parsed);
79
+ const summary =
80
+ `[${timeLabel}] [${levelLabel}] [${category}] ${message}`.trim();
81
+ const json = JSON.stringify(parsed, null, 2);
82
+ const id = `${timestamp ?? "unknown"}-${index}`;
83
+
84
+ entries.push({
85
+ id,
86
+ raw: parsed,
87
+ timestamp,
88
+ timeLabel,
89
+ levelLabel,
90
+ category,
91
+ message,
92
+ summary,
93
+ json,
94
+ });
95
+ } catch {
96
+ // Skip malformed JSON lines
97
+ }
98
+ });
99
+
100
+ const limit = options.limit ?? 100;
101
+ if (entries.length <= limit) {
102
+ return [...entries].reverse();
103
+ }
104
+
105
+ return entries.slice(-limit).reverse();
106
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { formatDate } from "./logger.js";
5
+
6
+ export interface LogFileInfo {
7
+ date: string;
8
+ path: string;
9
+ mtimeMs: number;
10
+ }
11
+
12
+ const LOG_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
13
+
14
+ export function resolveLogDir(cwd: string = process.cwd()): string {
15
+ const cwdBase = path.basename(cwd) || "workspace";
16
+ return path.join(os.homedir(), ".gwt", "logs", cwdBase);
17
+ }
18
+
19
+ export function buildLogFilePath(logDir: string, date: string): string {
20
+ return path.join(logDir, `${date}.jsonl`);
21
+ }
22
+
23
+ export function getTodayLogDate(): string {
24
+ return formatDate(new Date());
25
+ }
26
+
27
+ export async function readLogFileLines(filePath: string): Promise<string[]> {
28
+ try {
29
+ const content = await fs.readFile(filePath, "utf-8");
30
+ return content.split("\n").filter(Boolean);
31
+ } catch (error) {
32
+ const err = error as NodeJS.ErrnoException;
33
+ if (err.code === "ENOENT") {
34
+ return [];
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ export async function listLogFiles(logDir: string): Promise<LogFileInfo[]> {
41
+ try {
42
+ const entries = await fs.readdir(logDir, { withFileTypes: true });
43
+ const files: LogFileInfo[] = [];
44
+
45
+ for (const entry of entries) {
46
+ if (!entry.isFile()) continue;
47
+ if (!LOG_FILENAME_PATTERN.test(entry.name)) continue;
48
+
49
+ const date = entry.name.replace(/\.jsonl$/, "");
50
+ const fullPath = path.join(logDir, entry.name);
51
+ try {
52
+ const stat = await fs.stat(fullPath);
53
+ files.push({ date, path: fullPath, mtimeMs: stat.mtimeMs });
54
+ } catch {
55
+ // Ignore stat errors per-file
56
+ }
57
+ }
58
+
59
+ return files.sort((a, b) => b.date.localeCompare(a.date));
60
+ } catch (error) {
61
+ const err = error as NodeJS.ErrnoException;
62
+ if (err.code === "ENOENT") {
63
+ return [];
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ export async function listRecentLogFiles(
70
+ logDir: string,
71
+ days = 7,
72
+ ): Promise<LogFileInfo[]> {
73
+ const files = await listLogFiles(logDir);
74
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
75
+ return files.filter((file) => file.mtimeMs >= cutoff);
76
+ }