@akiojin/gwt 4.6.1 → 4.7.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 (71) hide show
  1. package/dist/claude.d.ts.map +1 -1
  2. package/dist/claude.js +5 -1
  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 +102 -1
  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 +6 -2
  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/clipboard.d.ts +7 -0
  29. package/dist/cli/ui/utils/clipboard.d.ts.map +1 -0
  30. package/dist/cli/ui/utils/clipboard.js +21 -0
  31. package/dist/cli/ui/utils/clipboard.js.map +1 -0
  32. package/dist/gemini.d.ts.map +1 -1
  33. package/dist/gemini.js +5 -1
  34. package/dist/gemini.js.map +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +9 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/logging/formatter.d.ts +15 -0
  39. package/dist/logging/formatter.d.ts.map +1 -0
  40. package/dist/logging/formatter.js +81 -0
  41. package/dist/logging/formatter.js.map +1 -0
  42. package/dist/logging/reader.d.ts +12 -0
  43. package/dist/logging/reader.d.ts.map +1 -0
  44. package/dist/logging/reader.js +63 -0
  45. package/dist/logging/reader.js.map +1 -0
  46. package/dist/worktree.d.ts.map +1 -1
  47. package/dist/worktree.js +57 -0
  48. package/dist/worktree.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/claude.ts +6 -1
  51. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +7 -3
  52. package/src/cli/ui/__tests__/components/common/Select.test.tsx +17 -11
  53. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +28 -2
  54. package/src/cli/ui/__tests__/components/screens/LogDetailScreen.test.tsx +57 -0
  55. package/src/cli/ui/__tests__/components/screens/LogListScreen.test.tsx +102 -0
  56. package/src/cli/ui/__tests__/hooks/useGitData.test.ts +197 -0
  57. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +19 -9
  58. package/src/cli/ui/__tests__/utils/clipboard.test.ts +65 -0
  59. package/src/cli/ui/components/App.tsx +177 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
  61. package/src/cli/ui/components/screens/LogDatePickerScreen.tsx +83 -0
  62. package/src/cli/ui/components/screens/LogDetailScreen.tsx +67 -0
  63. package/src/cli/ui/components/screens/LogListScreen.tsx +192 -0
  64. package/src/cli/ui/hooks/useGitData.ts +12 -3
  65. package/src/cli/ui/types.ts +3 -0
  66. package/src/cli/ui/utils/clipboard.ts +31 -0
  67. package/src/gemini.ts +6 -1
  68. package/src/index.ts +11 -2
  69. package/src/logging/formatter.ts +106 -0
  70. package/src/logging/reader.ts +76 -0
  71. package/src/worktree.ts +77 -0
@@ -93,6 +93,7 @@ export interface BranchListScreenProps {
93
93
  onCleanupCommand?: () => void;
94
94
  onRefresh?: () => void;
95
95
  onOpenProfiles?: () => void;
96
+ onOpenLogs?: () => void;
96
97
  loading?: boolean;
97
98
  error?: Error | null;
98
99
  lastUpdated?: Date | null;
@@ -128,6 +129,7 @@ export const BranchListScreen = React.memo(function BranchListScreen({
128
129
  onCleanupCommand,
129
130
  onRefresh,
130
131
  onOpenProfiles,
132
+ onOpenLogs,
131
133
  loading = false,
132
134
  error = null,
133
135
  lastUpdated = null,
@@ -273,6 +275,8 @@ export const BranchListScreen = React.memo(function BranchListScreen({
273
275
  onRefresh();
274
276
  } else if (input === "p" && onOpenProfiles) {
275
277
  onOpenProfiles();
278
+ } else if (input === "l" && onOpenLogs) {
279
+ onOpenLogs();
276
280
  }
277
281
  });
278
282
 
@@ -346,6 +350,7 @@ export const BranchListScreen = React.memo(function BranchListScreen({
346
350
  { key: "r", description: "Refresh" },
347
351
  { key: "c", description: "Cleanup" },
348
352
  { key: "p", description: "Profiles" },
353
+ { key: "l", description: "Logs" },
349
354
  ];
350
355
 
351
356
  const formatLatestCommit = useCallback((timestamp?: number) => {
@@ -622,7 +627,7 @@ export const BranchListScreen = React.memo(function BranchListScreen({
622
627
  onChange={setFilterQuery}
623
628
  onSubmit={() => {}} // No-op: filter is applied in real-time
624
629
  placeholder="Type to search..."
625
- blockKeys={["c", "r", "f"]} // Block shortcuts while typing
630
+ blockKeys={["c", "r", "f", "l"]} // Block shortcuts while typing
626
631
  />
627
632
  ) : (
628
633
  <Text dimColor>{filterQuery || "(press f to filter)"}</Text>
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { Header } from "../parts/Header.js";
4
+ import { Footer } from "../parts/Footer.js";
5
+ import { Select } from "../common/Select.js";
6
+ import { useAppInput } from "../../hooks/useAppInput.js";
7
+ import { useTerminalSize } from "../../hooks/useTerminalSize.js";
8
+ import type { LogFileInfo } from "../../../../logging/reader.js";
9
+
10
+ interface DateItem {
11
+ label: string;
12
+ value: string;
13
+ }
14
+
15
+ export interface LogDatePickerScreenProps {
16
+ dates: LogFileInfo[];
17
+ onBack: () => void;
18
+ onSelect: (date: string) => void;
19
+ version?: string | null;
20
+ }
21
+
22
+ export function LogDatePickerScreen({
23
+ dates,
24
+ onBack,
25
+ onSelect,
26
+ version,
27
+ }: LogDatePickerScreenProps) {
28
+ const { rows } = useTerminalSize();
29
+
30
+ useAppInput((input, key) => {
31
+ if (key.escape || input === "q") {
32
+ onBack();
33
+ }
34
+ });
35
+
36
+ const items: DateItem[] = dates.map((date) => ({
37
+ label: date.date,
38
+ value: date.date,
39
+ }));
40
+
41
+ const handleSelect = (item: DateItem) => {
42
+ onSelect(item.value);
43
+ };
44
+
45
+ const headerLines = 2;
46
+ const statsLines = 1;
47
+ const emptyLine = 1;
48
+ const footerLines = 1;
49
+ const fixedLines = headerLines + statsLines + emptyLine + footerLines;
50
+ const contentHeight = rows - fixedLines;
51
+ const limit = Math.max(5, contentHeight);
52
+
53
+ const footerActions = [
54
+ { key: "enter", description: "Select" },
55
+ { key: "esc", description: "Back" },
56
+ ];
57
+
58
+ return (
59
+ <Box flexDirection="column" height={rows}>
60
+ <Header title="gwt - Log Date" titleColor="cyan" version={version} />
61
+
62
+ <Box marginTop={1}>
63
+ <Text>
64
+ Total: <Text bold>{dates.length}</Text>
65
+ </Text>
66
+ </Box>
67
+
68
+ <Box height={1} />
69
+
70
+ <Box flexDirection="column" flexGrow={1}>
71
+ {dates.length === 0 ? (
72
+ <Box>
73
+ <Text dimColor>ログがありません</Text>
74
+ </Box>
75
+ ) : (
76
+ <Select items={items} onSelect={handleSelect} limit={limit} />
77
+ )}
78
+ </Box>
79
+
80
+ <Footer actions={footerActions} />
81
+ </Box>
82
+ );
83
+ }
@@ -0,0 +1,67 @@
1
+ import React, { useMemo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { Header } from "../parts/Header.js";
4
+ import { Footer } from "../parts/Footer.js";
5
+ import { useAppInput } from "../../hooks/useAppInput.js";
6
+ import { useTerminalSize } from "../../hooks/useTerminalSize.js";
7
+ import type { FormattedLogEntry } from "../../../../logging/formatter.js";
8
+
9
+ export interface LogDetailScreenProps {
10
+ entry: FormattedLogEntry | null;
11
+ onBack: () => void;
12
+ onCopy: (entry: FormattedLogEntry) => void;
13
+ notification?: { message: string; tone: "success" | "error" } | null;
14
+ version?: string | null;
15
+ }
16
+
17
+ export function LogDetailScreen({
18
+ entry,
19
+ onBack,
20
+ onCopy,
21
+ notification,
22
+ version,
23
+ }: LogDetailScreenProps) {
24
+ const { rows } = useTerminalSize();
25
+
26
+ useAppInput((input, key) => {
27
+ if (key.escape || input === "q") {
28
+ onBack();
29
+ return;
30
+ }
31
+
32
+ if (input === "c" && entry) {
33
+ onCopy(entry);
34
+ }
35
+ });
36
+
37
+ const jsonLines = useMemo<string[]>(() => {
38
+ if (!entry) return ["ログがありません"]; // fallback
39
+ return entry.json.split("\n");
40
+ }, [entry]);
41
+
42
+ const footerActions = [
43
+ { key: "c", description: "Copy" },
44
+ { key: "esc", description: "Back" },
45
+ ];
46
+
47
+ return (
48
+ <Box flexDirection="column" height={rows}>
49
+ <Header title="gwt - Log Detail" titleColor="cyan" version={version} />
50
+
51
+ {notification ? (
52
+ <Box marginTop={1}>
53
+ <Text color={notification.tone === "error" ? "red" : "green"}>
54
+ {notification.message}
55
+ </Text>
56
+ </Box>
57
+ ) : null}
58
+ <Box flexDirection="column" flexGrow={1} marginTop={1}>
59
+ {jsonLines.map((line, index) => (
60
+ <Text key={`${line}-${index}`}>{line}</Text>
61
+ ))}
62
+ </Box>
63
+
64
+ <Footer actions={footerActions} />
65
+ </Box>
66
+ );
67
+ }
@@ -0,0 +1,192 @@
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import { Box, Text } from "ink";
3
+ import stringWidth from "string-width";
4
+ import { Header } from "../parts/Header.js";
5
+ import { Footer } from "../parts/Footer.js";
6
+ import { Select } from "../common/Select.js";
7
+ import { useAppInput } from "../../hooks/useAppInput.js";
8
+ import { useTerminalSize } from "../../hooks/useTerminalSize.js";
9
+ import type { FormattedLogEntry } from "../../../../logging/formatter.js";
10
+
11
+ interface LogListItem {
12
+ label: string;
13
+ value: string;
14
+ entry: FormattedLogEntry;
15
+ }
16
+
17
+ export interface LogListScreenProps {
18
+ entries: FormattedLogEntry[];
19
+ loading?: boolean;
20
+ error?: string | null;
21
+ onBack: () => void;
22
+ onSelect: (entry: FormattedLogEntry) => void;
23
+ onCopy: (entry: FormattedLogEntry) => void;
24
+ onPickDate?: () => void;
25
+ notification?: { message: string; tone: "success" | "error" } | null;
26
+ version?: string | null;
27
+ selectedDate?: string | null;
28
+ }
29
+
30
+ const truncateToWidth = (value: string, maxWidth: number): string => {
31
+ if (maxWidth <= 0) return "";
32
+ if (stringWidth(value) <= maxWidth) return value;
33
+ const ellipsis = "…";
34
+ const ellipsisWidth = stringWidth(ellipsis);
35
+ if (ellipsisWidth >= maxWidth) return ellipsis;
36
+
37
+ let result = "";
38
+ for (const char of Array.from(value)) {
39
+ if (stringWidth(result + char) + ellipsisWidth > maxWidth) {
40
+ break;
41
+ }
42
+ result += char;
43
+ }
44
+ return result + ellipsis;
45
+ };
46
+
47
+ const padToWidth = (value: string, width: number): string => {
48
+ if (width <= 0) return "";
49
+ if (stringWidth(value) >= width) return value;
50
+ return value + " ".repeat(width - stringWidth(value));
51
+ };
52
+
53
+ export function LogListScreen({
54
+ entries,
55
+ loading = false,
56
+ error = null,
57
+ onBack,
58
+ onSelect,
59
+ onCopy,
60
+ onPickDate,
61
+ notification,
62
+ version,
63
+ selectedDate,
64
+ }: LogListScreenProps) {
65
+ const { rows, columns } = useTerminalSize();
66
+ const [selectedIndex, setSelectedIndex] = useState(0);
67
+
68
+ const maxLabelWidth = Math.max(10, columns - 2);
69
+
70
+ const items = useMemo<LogListItem[]>(
71
+ () =>
72
+ entries.map((entry) => ({
73
+ label: truncateToWidth(entry.summary, maxLabelWidth),
74
+ value: entry.id,
75
+ entry,
76
+ })),
77
+ [entries, maxLabelWidth],
78
+ );
79
+
80
+ const handleSelect = useCallback(
81
+ (item: LogListItem) => {
82
+ onSelect(item.entry);
83
+ },
84
+ [onSelect],
85
+ );
86
+
87
+ const handleCopy = useCallback(() => {
88
+ const entry = entries[selectedIndex];
89
+ if (entry) {
90
+ onCopy(entry);
91
+ }
92
+ }, [entries, selectedIndex, onCopy]);
93
+
94
+ useAppInput((input, key) => {
95
+ if (key.escape || input === "q") {
96
+ onBack();
97
+ return;
98
+ }
99
+
100
+ if (input === "c") {
101
+ handleCopy();
102
+ return;
103
+ }
104
+
105
+ if (input === "d" && onPickDate) {
106
+ onPickDate();
107
+ }
108
+ });
109
+
110
+ const renderItem = useCallback(
111
+ (item: LogListItem, isSelected: boolean) => {
112
+ if (!isSelected) {
113
+ return <Text>{item.label}</Text>;
114
+ }
115
+ const padded = padToWidth(item.label, maxLabelWidth);
116
+ const output = `\u001b[46m\u001b[30m${padded}\u001b[0m`;
117
+ return <Text>{output}</Text>;
118
+ },
119
+ [maxLabelWidth],
120
+ );
121
+
122
+ const headerLines = 2;
123
+ const statsLines = 1;
124
+ const emptyLine = 1;
125
+ const footerLines = 1;
126
+ const fixedLines = headerLines + statsLines + emptyLine + footerLines;
127
+ const contentHeight = rows - fixedLines;
128
+ const limit = Math.max(5, contentHeight);
129
+
130
+ const footerActions = [
131
+ { key: "enter", description: "Detail" },
132
+ { key: "c", description: "Copy" },
133
+ { key: "d", description: "Date" },
134
+ { key: "esc", description: "Back" },
135
+ ];
136
+
137
+ return (
138
+ <Box flexDirection="column" height={rows}>
139
+ <Header title="gwt - Log Viewer" titleColor="cyan" version={version} />
140
+
141
+ {notification ? (
142
+ <Box marginTop={1}>
143
+ <Text color={notification.tone === "error" ? "red" : "green"}>
144
+ {notification.message}
145
+ </Text>
146
+ </Box>
147
+ ) : null}
148
+
149
+ <Box marginTop={1}>
150
+ <Box marginRight={2}>
151
+ <Text>
152
+ Date: <Text bold>{selectedDate ?? "---"}</Text>
153
+ </Text>
154
+ </Box>
155
+ <Text>
156
+ Total: <Text bold>{entries.length}</Text>
157
+ </Text>
158
+ </Box>
159
+
160
+ <Box height={1} />
161
+
162
+ <Box flexDirection="column" flexGrow={1}>
163
+ {loading ? (
164
+ <Box>
165
+ <Text dimColor>Loading logs...</Text>
166
+ </Box>
167
+ ) : entries.length === 0 ? (
168
+ <Box>
169
+ <Text dimColor>ログがありません</Text>
170
+ </Box>
171
+ ) : (
172
+ <Select
173
+ items={items}
174
+ onSelect={handleSelect}
175
+ limit={limit}
176
+ selectedIndex={selectedIndex}
177
+ onSelectedIndexChange={setSelectedIndex}
178
+ renderItem={renderItem}
179
+ />
180
+ )}
181
+
182
+ {error ? (
183
+ <Box marginTop={1}>
184
+ <Text color="red">{error}</Text>
185
+ </Box>
186
+ ) : null}
187
+ </Box>
188
+
189
+ <Footer actions={footerActions} />
190
+ </Box>
191
+ );
192
+ }
@@ -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"
@@ -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/gemini.ts CHANGED
@@ -201,7 +201,12 @@ export async function launchGeminiCLI(
201
201
  );
202
202
  console.log(chalk.yellow(" npm install -g @google/gemini-cli"));
203
203
  console.log("");
204
- await new Promise((resolve) => setTimeout(resolve, 2000));
204
+ const shouldSkipDelay =
205
+ typeof process !== "undefined" &&
206
+ (process.env?.NODE_ENV === "test" || Boolean(process.env?.VITEST));
207
+ if (!shouldSkipDelay) {
208
+ await new Promise((resolve) => setTimeout(resolve, 2000));
209
+ }
205
210
  return await run("bunx", [GEMINI_CLI_PACKAGE, ...runArgs]);
206
211
  };
207
212
 
package/src/index.ts CHANGED
@@ -768,11 +768,13 @@ export async function handleAIToolWorkflow(
768
768
  lastSessionId: finalSessionId,
769
769
  });
770
770
 
771
+ let uncommittedExists = false;
771
772
  try {
772
773
  const [hasUncommitted, hasUnpushed] = await Promise.all([
773
774
  hasUncommittedChanges(worktreePath),
774
775
  hasUnpushedCommits(worktreePath, branch),
775
776
  ]);
777
+ uncommittedExists = hasUncommitted;
776
778
 
777
779
  if (hasUncommitted) {
778
780
  const uncommittedCount = await getUncommittedChangesCount(worktreePath);
@@ -807,8 +809,15 @@ export async function handleAIToolWorkflow(
807
809
  const details = error instanceof Error ? error.message : String(error);
808
810
  printWarning(`Failed to check git status after session: ${details}`);
809
811
  }
810
- // Small buffer before returning to branch list to avoid abrupt screen swap
811
- await new Promise((resolve) => setTimeout(resolve, POST_SESSION_DELAY_MS));
812
+
813
+ if (uncommittedExists) {
814
+ await waitForEnter("Press Enter to return to the main menu...");
815
+ } else {
816
+ // Small buffer before returning to branch list to avoid abrupt screen swap
817
+ await new Promise((resolve) =>
818
+ setTimeout(resolve, POST_SESSION_DELAY_MS),
819
+ );
820
+ }
812
821
  printInfo("Session completed successfully. Returning to main menu...");
813
822
  return;
814
823
  } catch (error) {
@@ -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
+ }