@akiojin/gwt 2.12.1 → 2.13.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 (76) hide show
  1. package/dist/cli/ui/components/App.d.ts.map +1 -1
  2. package/dist/cli/ui/components/App.js +72 -3
  3. package/dist/cli/ui/components/App.js.map +1 -1
  4. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +3 -1
  5. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  6. package/dist/cli/ui/components/screens/BranchListScreen.js +154 -32
  7. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  8. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  9. package/dist/cli/ui/hooks/useGitData.js +17 -0
  10. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  11. package/dist/cli/ui/types.d.ts +2 -0
  12. package/dist/cli/ui/types.d.ts.map +1 -1
  13. package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
  14. package/dist/cli/ui/utils/branchFormatter.js +7 -2
  15. package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
  16. package/dist/cli/ui/utils/modelOptions.d.ts.map +1 -1
  17. package/dist/cli/ui/utils/modelOptions.js +7 -0
  18. package/dist/cli/ui/utils/modelOptions.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/logging/logger.d.ts +24 -0
  23. package/dist/logging/logger.d.ts.map +1 -0
  24. package/dist/logging/logger.js +57 -0
  25. package/dist/logging/logger.js.map +1 -0
  26. package/dist/logging/rotation.d.ts +6 -0
  27. package/dist/logging/rotation.d.ts.map +1 -0
  28. package/dist/logging/rotation.js +26 -0
  29. package/dist/logging/rotation.js.map +1 -0
  30. package/dist/web/server/index.d.ts.map +1 -1
  31. package/dist/web/server/index.js +3 -3
  32. package/dist/web/server/index.js.map +1 -1
  33. package/dist/web/server/routes/branches.d.ts +2 -2
  34. package/dist/web/server/routes/branches.d.ts.map +1 -1
  35. package/dist/web/server/routes/branches.js.map +1 -1
  36. package/dist/web/server/routes/config.d.ts +2 -2
  37. package/dist/web/server/routes/config.d.ts.map +1 -1
  38. package/dist/web/server/routes/config.js.map +1 -1
  39. package/dist/web/server/routes/index.d.ts +2 -2
  40. package/dist/web/server/routes/index.d.ts.map +1 -1
  41. package/dist/web/server/routes/index.js.map +1 -1
  42. package/dist/web/server/routes/sessions.d.ts +2 -2
  43. package/dist/web/server/routes/sessions.d.ts.map +1 -1
  44. package/dist/web/server/routes/sessions.js.map +1 -1
  45. package/dist/web/server/routes/worktrees.d.ts +2 -2
  46. package/dist/web/server/routes/worktrees.d.ts.map +1 -1
  47. package/dist/web/server/routes/worktrees.js.map +1 -1
  48. package/dist/web/server/types.d.ts +4 -0
  49. package/dist/web/server/types.d.ts.map +1 -0
  50. package/dist/web/server/types.js +2 -0
  51. package/dist/web/server/types.js.map +1 -0
  52. package/dist/worktree.d.ts +1 -0
  53. package/dist/worktree.d.ts.map +1 -1
  54. package/dist/worktree.js.map +1 -1
  55. package/package.json +3 -2
  56. package/src/cli/ui/__tests__/components/ModelSelectorScreen.initial.test.tsx +13 -13
  57. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +81 -33
  58. package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +7 -3
  59. package/src/cli/ui/components/App.tsx +88 -2
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +198 -32
  61. package/src/cli/ui/hooks/useGitData.ts +20 -0
  62. package/src/cli/ui/types.ts +3 -0
  63. package/src/cli/ui/utils/branchFormatter.ts +7 -2
  64. package/src/cli/ui/utils/modelOptions.test.ts +14 -0
  65. package/src/cli/ui/utils/modelOptions.ts +7 -0
  66. package/src/index.ts +7 -0
  67. package/src/logging/logger.ts +79 -0
  68. package/src/logging/rotation.ts +25 -0
  69. package/src/web/server/index.ts +6 -4
  70. package/src/web/server/routes/branches.ts +2 -2
  71. package/src/web/server/routes/config.ts +2 -2
  72. package/src/web/server/routes/index.ts +2 -2
  73. package/src/web/server/routes/sessions.ts +2 -2
  74. package/src/web/server/routes/worktrees.ts +2 -2
  75. package/src/web/server/types.ts +14 -0
  76. package/src/worktree.ts +1 -0
@@ -172,6 +172,8 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
172
172
  color?: "cyan" | "green" | "yellow" | "red";
173
173
  } | null>(null);
174
174
  const [hiddenBranches, setHiddenBranches] = useState<string[]>([]);
175
+ const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
176
+ const [safeBranches, setSafeBranches] = useState<Set<string>>(new Set());
175
177
  const spinnerFrameIndexRef = useRef(0);
176
178
  const [spinnerFrameIndex, setSpinnerFrameIndex] = useState(0);
177
179
  const completionTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -252,6 +254,42 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
252
254
  }
253
255
  }, [branches, hiddenBranches]);
254
256
 
257
+ // Remove selections that no longer exist (hidden or disappeared)
258
+ useEffect(() => {
259
+ setSelectedBranches((prev) =>
260
+ prev.filter(
261
+ (name) =>
262
+ branches.some((b) => b.name === name) &&
263
+ !hiddenBranches.includes(name),
264
+ ),
265
+ );
266
+ }, [branches, hiddenBranches]);
267
+
268
+ // Precompute safe-to-clean branches using cleanup candidate logic
269
+ useEffect(() => {
270
+ let cancelled = false;
271
+ (async () => {
272
+ try {
273
+ const targets = await getMergedPRWorktrees();
274
+ if (cancelled) return;
275
+ const safe = new Set(
276
+ targets
277
+ .filter(
278
+ (t) => !t.hasUncommittedChanges && !t.hasUnpushedCommits,
279
+ )
280
+ .map((t) => t.branch),
281
+ );
282
+ setSafeBranches(safe);
283
+ } catch {
284
+ if (cancelled) return;
285
+ setSafeBranches(new Set());
286
+ }
287
+ })();
288
+ return () => {
289
+ cancelled = true;
290
+ };
291
+ }, [branches, worktrees]);
292
+
255
293
  // Load available sessions when entering session selector
256
294
  useEffect(() => {
257
295
  if (currentScreen !== "session-selector") {
@@ -530,10 +568,22 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
530
568
  locked: false,
531
569
  prunable: wt.isAccessible === false,
532
570
  isAccessible: wt.isAccessible ?? true,
571
+ ...(wt.hasUncommittedChanges !== undefined
572
+ ? { hasUncommittedChanges: wt.hasUncommittedChanges }
573
+ : {}),
533
574
  });
534
575
  }
535
- return formatBranchItems(visibleBranches, worktreeMap);
536
- }, [branchHash, worktreeHash, visibleBranches, worktrees]);
576
+ const baseItems = formatBranchItems(visibleBranches, worktreeMap);
577
+ return baseItems.map((item) => ({
578
+ ...item,
579
+ safeToCleanup: safeBranches.has(item.name),
580
+ }));
581
+ }, [branchHash, worktreeHash, visibleBranches, worktrees, safeBranches]);
582
+
583
+ const selectedBranchSet = useMemo(
584
+ () => new Set(selectedBranches),
585
+ [selectedBranches],
586
+ );
537
587
 
538
588
  // Calculate statistics (memoized for performance)
539
589
  const stats = useMemo(
@@ -626,6 +676,18 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
626
676
  [isProtectedBranchName],
627
677
  );
628
678
 
679
+ const toggleBranchSelection = useCallback((branchName: string) => {
680
+ setSelectedBranches((prev) => {
681
+ const set = new Set(prev);
682
+ if (set.has(branchName)) {
683
+ set.delete(branchName);
684
+ } else {
685
+ set.add(branchName);
686
+ }
687
+ return Array.from(set);
688
+ });
689
+ }, []);
690
+
629
691
  const protectedBranchInfo = useMemo(() => {
630
692
  if (!selectedBranch) {
631
693
  return null;
@@ -850,6 +912,9 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
850
912
  succeededBranches.forEach((branch) => merged.add(branch));
851
913
  return Array.from(merged);
852
914
  });
915
+ setSelectedBranches((prev) =>
916
+ prev.filter((name) => !succeededBranches.includes(name)),
917
+ );
853
918
  }
854
919
  refresh();
855
920
  completionTimerRef.current = null;
@@ -896,6 +961,24 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
896
961
  return;
897
962
  }
898
963
 
964
+ // Manual selection: restrict targets when選択がある
965
+ if (selectedBranchSet.size > 0) {
966
+ targets = targets.filter((t) => selectedBranchSet.has(t.branch));
967
+ if (targets.length === 0) {
968
+ setCleanupIndicators({});
969
+ setCleanupFooterMessage({
970
+ text: "⚠️ No cleanup candidates among selected branches.",
971
+ color: "yellow",
972
+ });
973
+ setCleanupInputLocked(false);
974
+ completionTimerRef.current = setTimeout(() => {
975
+ setCleanupFooterMessage(null);
976
+ completionTimerRef.current = null;
977
+ }, COMPLETION_HOLD_DURATION_MS);
978
+ return;
979
+ }
980
+ }
981
+
899
982
  // Reset hidden branches that may already be gone
900
983
  setHiddenBranches((prev) =>
901
984
  prev.filter(
@@ -1012,6 +1095,7 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
1012
1095
  getMergedPRWorktrees,
1013
1096
  refresh,
1014
1097
  removeWorktree,
1098
+ selectedBranchSet,
1015
1099
  ]);
1016
1100
 
1017
1101
  // Handle AI tool selection
@@ -1177,6 +1261,8 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
1177
1261
  }}
1178
1262
  version={version}
1179
1263
  workingDirectory={workingDirectory}
1264
+ selectedBranches={selectedBranches}
1265
+ onToggleSelect={toggleBranchSelection}
1180
1266
  />
1181
1267
  );
1182
1268
 
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useState, useMemo } from "react";
1
+ import React, { useCallback, useState, useMemo, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { Header } from "../parts/Header.js";
4
4
  import { Stats } from "../parts/Stats.js";
@@ -9,8 +9,11 @@ import { LoadingIndicator } from "../common/LoadingIndicator.js";
9
9
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
10
10
  import type { BranchItem, Statistics } from "../../types.js";
11
11
  import stringWidth from "string-width";
12
+ import stripAnsi from "strip-ansi";
12
13
  import chalk from "chalk";
13
14
 
15
+ // Emoji 幅は端末によって 1 または 2 になることがあるため、最小幅を上書きして
16
+ // 実測より小さくならないようにする(過小評価=折り返しの原因を防ぐ)
14
17
  const WIDTH_OVERRIDES: Record<string, number> = {
15
18
  // Remote icon
16
19
  "☁": 1,
@@ -22,19 +25,24 @@ const WIDTH_OVERRIDES: Record<string, number> = {
22
25
  "🚀": 1,
23
26
  "📌": 1,
24
27
  // Worktree status icons
25
- "🟢": 1,
28
+ "🟢": 2,
29
+ "⚪": 2,
26
30
  "🟠": 1,
27
31
  // Change status icons
28
32
  "👉": 1,
29
33
  "💾": 1,
30
34
  "📤": 1,
31
35
  "🔃": 1,
32
- "✅": 1,
33
- "⚠️": 1,
36
+ "✅": 2,
37
+ "": 2,
38
+ "⚠️": 2,
39
+ "🛡": 2,
34
40
  // Remote markers
35
- "🔗": 1,
36
- "💻": 1,
37
- "☁️": 1,
41
+ "🔗": 2,
42
+ "💻": 2,
43
+ "☁️": 2,
44
+ "☑": 2,
45
+ "☐": 2,
38
46
  };
39
47
 
40
48
  const getCharWidth = (char: string): number => {
@@ -88,6 +96,8 @@ export interface BranchListScreenProps {
88
96
  testOnFilterModeChange?: (mode: boolean) => void;
89
97
  testFilterQuery?: string;
90
98
  testOnFilterQueryChange?: (query: string) => void;
99
+ selectedBranches?: string[];
100
+ onToggleSelect?: (branchName: string) => void;
91
101
  }
92
102
 
93
103
  /**
@@ -111,11 +121,13 @@ export function BranchListScreen({
111
121
  testOnFilterModeChange,
112
122
  testFilterQuery,
113
123
  testOnFilterQueryChange,
124
+ selectedBranches = [],
125
+ onToggleSelect,
114
126
  }: BranchListScreenProps) {
115
127
  const { rows } = useTerminalSize();
116
- const COLUMN_WIDTH = 2;
117
- const SYNC_COLUMN_WIDTH = 6;
118
- const headerText = ` ${"Ty".padEnd(COLUMN_WIDTH)}${"Wt".padEnd(COLUMN_WIDTH)}${"St".padEnd(COLUMN_WIDTH)}${"Rm".padEnd(COLUMN_WIDTH)}${"Sync".padEnd(SYNC_COLUMN_WIDTH)}Branch`;
128
+ const headerText =
129
+ " Legend: [ ]/[ * ] select 🟢/⚪ worktree 🛡/⚠ safe";
130
+ const selectedSet = useMemo(() => new Set(selectedBranches), [selectedBranches]);
119
131
 
120
132
  // Filter state - allow test control via props
121
133
  const [internalFilterQuery, setInternalFilterQuery] = useState("");
@@ -142,6 +154,9 @@ export function BranchListScreen({
142
154
  [testOnFilterModeChange],
143
155
  );
144
156
 
157
+ // Cursor position for Select (controlled to enable space toggle)
158
+ const [selectedIndex, setSelectedIndex] = useState(0);
159
+
145
160
  // Handle keyboard input
146
161
  // Note: Input component blocks specific keys (c/r/f) using blockKeys prop
147
162
  // This prevents shortcuts from triggering while typing in the filter
@@ -170,6 +185,15 @@ export function BranchListScreen({
170
185
  return;
171
186
  }
172
187
 
188
+ // Toggle selection with space (only in branch selection mode)
189
+ if (input === " " && !filterMode) {
190
+ const target = filteredBranches[selectedIndex];
191
+ if (target) {
192
+ onToggleSelect?.(target.name);
193
+ }
194
+ return;
195
+ }
196
+
173
197
  // Disable global shortcuts while in filter mode
174
198
  if (filterMode) {
175
199
  return;
@@ -205,6 +229,15 @@ export function BranchListScreen({
205
229
  });
206
230
  }, [branches, filterQuery]);
207
231
 
232
+ useEffect(() => {
233
+ setSelectedIndex((prev) => {
234
+ if (filteredBranches.length === 0) {
235
+ return 0;
236
+ }
237
+ return Math.min(prev, filteredBranches.length - 1);
238
+ });
239
+ }, [filteredBranches.length]);
240
+
208
241
  // Calculate available space for branch list
209
242
  // Header: 2 lines (title + divider)
210
243
  // Filter input: 1 line
@@ -275,18 +308,67 @@ export function BranchListScreen({
275
308
  return result + ellipsis;
276
309
  }, []);
277
310
 
311
+ const colorToolLabel = useCallback((label: string, toolId?: string | null) => {
312
+ switch (toolId) {
313
+ case "claude-code":
314
+ return chalk.hex("#ffaf00")(label); // orange-ish
315
+ case "codex-cli":
316
+ return chalk.cyan(label);
317
+ case "gemini-cli":
318
+ return chalk.magenta(label);
319
+ case "qwen-cli":
320
+ return chalk.green(label);
321
+ default: {
322
+ const trimmed = label.trim().toLowerCase();
323
+ if (!toolId || trimmed === "unknown") {
324
+ return chalk.gray(label);
325
+ }
326
+ return chalk.white(label);
327
+ }
328
+ }
329
+ }, []);
330
+
278
331
  const renderBranchRow = useCallback(
279
332
  (item: BranchItem, isSelected: boolean, context: { columns: number }) => {
280
- // Use a small safety margin to avoid terminal-dependent wrapping
333
+ // 端末幅ピッタリでの自動折返しを避けるため、1桁だけ余白を取る
281
334
  const columns = Math.max(20, context.columns - 1);
335
+ const visibleWidth = (value: string) =>
336
+ measureDisplayWidth(stripAnsi(value));
282
337
  const arrow = isSelected ? ">" : " ";
283
- const commitText = formatLatestCommit(item.latestCommitTimestamp);
284
- const infoText =
285
- item.lastToolUsage && item.lastToolUsageLabel
286
- ? item.lastToolUsageLabel
287
- : `${chalk.gray("Unknown")}${commitText !== "---" ? ` | ${commitText}` : ""}`;
288
- const timestampText = infoText;
289
- const timestampWidth = stringWidth(timestampText);
338
+ let commitText = "---";
339
+ if (item.latestCommitTimestamp) {
340
+ commitText = formatLatestCommit(item.latestCommitTimestamp);
341
+ } else if (item.lastToolUsage?.timestamp) {
342
+ const seconds = Math.floor(item.lastToolUsage.timestamp / 1000);
343
+ commitText = formatLatestCommit(seconds);
344
+ }
345
+ const toolLabelRaw =
346
+ item.lastToolUsageLabel?.split("|")?.[0]?.trim() ??
347
+ item.lastToolUsage?.toolId ??
348
+ "Unknown";
349
+
350
+ const formatFixedWidth = (value: string, targetWidth: number) => {
351
+ let v = value;
352
+ if (measureDisplayWidth(v) > targetWidth) {
353
+ v = truncateToWidth(v, targetWidth);
354
+ }
355
+ const padding = Math.max(0, targetWidth - measureDisplayWidth(v));
356
+ return v + " ".repeat(padding);
357
+ };
358
+
359
+ const TOOL_WIDTH = 7;
360
+ const DATE_WIDTH = 16; // "YYYY-MM-DD HH:mm"
361
+ const paddedTool = formatFixedWidth(toolLabelRaw, TOOL_WIDTH);
362
+ const paddedDate =
363
+ commitText === "---"
364
+ ? " ".repeat(DATE_WIDTH)
365
+ : commitText.padStart(DATE_WIDTH, " ");
366
+ const timestampText = `${paddedTool} | ${paddedDate}`;
367
+ const displayTimestampText = `${colorToolLabel(
368
+ paddedTool,
369
+ item.lastToolUsage?.toolId,
370
+ )} | ${paddedDate}`;
371
+ const timestampWidth = measureDisplayWidth(timestampText);
290
372
 
291
373
  const indicatorInfo = cleanupUI?.indicators?.[item.name];
292
374
  let indicatorIcon = indicatorInfo?.icon ?? "";
@@ -309,30 +391,112 @@ export function BranchListScreen({
309
391
  }
310
392
  }
311
393
  const indicatorPrefix = indicatorIcon ? `${indicatorIcon} ` : "";
312
- const staticPrefix = `${arrow} ${indicatorPrefix}`;
313
- const staticPrefixWidth = measureDisplayWidth(staticPrefix);
394
+
395
+ const isChecked = selectedSet.has(item.name);
396
+ const selectionIcon = isChecked ? "[*]" : "[ ]";
397
+ const hasWorktree =
398
+ item.worktreeStatus === "active" ||
399
+ item.worktreeStatus === "inaccessible";
400
+ const worktreeIcon = hasWorktree ? chalk.green("🟢") : chalk.gray("⚪");
401
+ const safeIcon =
402
+ item.safeToCleanup === true ? chalk.green("🛡") : chalk.yellow("⚠");
403
+ const stateCluster = `${selectionIcon} ${worktreeIcon} ${safeIcon}`;
404
+
405
+ const staticPrefix = `${arrow} ${indicatorPrefix}${stateCluster} `;
406
+ const staticPrefixWidth = visibleWidth(staticPrefix);
314
407
  const maxLeftDisplayWidth = Math.max(0, columns - timestampWidth - 1);
315
408
  const maxLabelWidth = Math.max(
316
409
  0,
317
410
  maxLeftDisplayWidth - staticPrefixWidth,
318
411
  );
319
- const truncatedLabel = truncateToWidth(item.label, maxLabelWidth);
320
- const leftText = `${staticPrefix}${truncatedLabel}`;
412
+ const displayLabel =
413
+ item.type === "remote" && item.remoteName ? item.remoteName : item.name;
414
+ let truncatedLabel = truncateToWidth(displayLabel, maxLabelWidth);
415
+ let leftText = `${staticPrefix}${truncatedLabel}`;
416
+
417
+ let leftDisplayWidth = visibleWidth(leftText);
418
+ // Gap between labelとツール/日時。右端に寄せるため必要分だけ確保。
419
+ let gapWidth = Math.max(1, columns - leftDisplayWidth - timestampWidth);
420
+
421
+ // もしまだオーバーする場合、隙間→ラベルの順で削って収める
422
+ let totalWidth = leftDisplayWidth + gapWidth + timestampWidth;
423
+ if (totalWidth > columns) {
424
+ const overflow = totalWidth - columns;
425
+ const reducedGap = Math.max(1, gapWidth - overflow);
426
+ gapWidth = reducedGap;
427
+ totalWidth = leftDisplayWidth + gapWidth + timestampWidth;
428
+ }
429
+ if (leftDisplayWidth + gapWidth + timestampWidth > columns) {
430
+ const extra = leftDisplayWidth + gapWidth + timestampWidth - columns;
431
+ const newLabelWidth = Math.max(
432
+ 0,
433
+ measureDisplayWidth(truncatedLabel) - extra,
434
+ );
435
+ truncatedLabel = truncateToWidth(displayLabel, newLabelWidth);
436
+ leftText = `${staticPrefix}${truncatedLabel}`;
437
+ leftDisplayWidth = visibleWidth(leftText);
438
+ gapWidth = Math.max(1, columns - leftDisplayWidth - timestampWidth);
439
+ }
440
+
441
+ const buildLine = () =>
442
+ `${leftText}${" ".repeat(gapWidth)}${timestampText}`;
443
+
444
+ let line = buildLine();
445
+ // Replace timestamp with colorized tool name (keep alignment from width calc)
446
+ let lineWithColoredTimestamp = line.replace(
447
+ timestampText,
448
+ displayTimestampText,
449
+ );
321
450
 
322
- const leftDisplayWidth = measureDisplayWidth(leftText);
323
- const gapWidth = Math.max(1, columns - leftDisplayWidth - timestampWidth);
451
+ // 端末幅を超えた場合は隙間→ラベルの順で詰めて収める
452
+ const clampToWidth = () => {
453
+ const finalWidth = measureDisplayWidth(stripAnsi(lineWithColoredTimestamp));
454
+ if (finalWidth <= columns) {
455
+ return;
456
+ }
457
+ const overflow = finalWidth - columns;
458
+ const reducedGap = Math.max(1, gapWidth - overflow);
459
+ gapWidth = reducedGap;
460
+ line = buildLine();
461
+ lineWithColoredTimestamp = line.replace(
462
+ timestampText,
463
+ displayTimestampText,
464
+ );
465
+ const widthAfterGap = measureDisplayWidth(
466
+ stripAnsi(lineWithColoredTimestamp),
467
+ );
468
+ if (widthAfterGap > columns) {
469
+ const extra = widthAfterGap - columns;
470
+ const newLabelWidth = Math.max(
471
+ 0,
472
+ measureDisplayWidth(truncatedLabel) - extra,
473
+ );
474
+ truncatedLabel = truncateToWidth(displayLabel, newLabelWidth);
475
+ leftText = `${staticPrefix}${truncatedLabel}`;
476
+ leftDisplayWidth = visibleWidth(leftText);
477
+ gapWidth = Math.max(1, columns - leftDisplayWidth - timestampWidth);
478
+ line = buildLine();
479
+ lineWithColoredTimestamp = line.replace(
480
+ timestampText,
481
+ displayTimestampText,
482
+ );
483
+ }
484
+ };
324
485
 
325
- let line = `${leftText}${" ".repeat(gapWidth)}${timestampText}`;
326
- const totalDisplayWidth = leftDisplayWidth + gapWidth + timestampWidth;
327
- const paddingWidth = Math.max(0, columns - totalDisplayWidth);
328
- if (paddingWidth > 0) {
329
- line += " ".repeat(paddingWidth);
330
- }
486
+ clampToWidth();
331
487
 
332
- const output = isSelected ? `\u001b[46m\u001b[30m${line}\u001b[0m` : line;
488
+ const output = isSelected
489
+ ? `\u001b[46m\u001b[30m${lineWithColoredTimestamp}\u001b[0m`
490
+ : lineWithColoredTimestamp;
333
491
  return <Text>{output}</Text>;
334
492
  },
335
- [cleanupUI, formatLatestCommit, truncateToWidth],
493
+ [
494
+ cleanupUI,
495
+ formatLatestCommit,
496
+ truncateToWidth,
497
+ selectedSet,
498
+ colorToolLabel,
499
+ ],
336
500
  );
337
501
 
338
502
  return (
@@ -425,6 +589,8 @@ export function BranchListScreen({
425
589
  disabled={Boolean(cleanupUI?.inputLocked)}
426
590
  renderIndicator={() => null}
427
591
  renderItem={renderBranchRow}
592
+ selectedIndex={selectedIndex}
593
+ onSelectedIndexChange={setSelectedIndex}
428
594
  />
429
595
  </>
430
596
  )}
@@ -12,6 +12,7 @@ import { getPullRequestByBranch } from "../../../github.js";
12
12
  import type { BranchInfo, WorktreeInfo } from "../types.js";
13
13
  import type { WorktreeInfo as GitWorktreeInfo } from "../../../worktree.js";
14
14
  import { getLastToolUsageMap } from "../../../config/index.js";
15
+ import { hasUncommittedChanges } from "../../../git.js";
15
16
 
16
17
  export interface UseGitDataOptions {
17
18
  enableAutoRefresh?: boolean;
@@ -65,6 +66,22 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
65
66
  }
66
67
  worktreesData = [];
67
68
  }
69
+
70
+ // enrich worktrees with uncommitted status (only for accessible paths)
71
+ worktreesData = await Promise.all(
72
+ worktreesData.map(async (wt) => {
73
+ if (wt.isAccessible === false) {
74
+ return wt;
75
+ }
76
+ try {
77
+ const hasUncommitted = await hasUncommittedChanges(wt.path);
78
+ return { ...wt, hasUncommittedChanges: hasUncommitted };
79
+ } catch {
80
+ return wt;
81
+ }
82
+ }),
83
+ );
84
+
68
85
  const lastToolUsageMap = await getLastToolUsageMap(repoRoot);
69
86
 
70
87
  // upstream情報とdivergence情報を取得
@@ -109,6 +126,9 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
109
126
  locked: false, // worktree.ts doesn't expose locked status
110
127
  prunable: worktree.isAccessible === false,
111
128
  isAccessible: worktree.isAccessible ?? true, // Default to true if undefined
129
+ ...(worktree.hasUncommittedChanges !== undefined
130
+ ? { hasUncommittedChanges: worktree.hasUncommittedChanges }
131
+ : {}),
112
132
  };
113
133
  worktreeMap.set(worktree.branch, uiWorktreeInfo);
114
134
  }
@@ -6,6 +6,7 @@ export interface WorktreeInfo {
6
6
  locked: boolean;
7
7
  prunable: boolean;
8
8
  isAccessible?: boolean;
9
+ hasUncommittedChanges?: boolean;
9
10
  }
10
11
 
11
12
  export type AITool = string;
@@ -229,6 +230,8 @@ export interface BranchItem extends BranchInfo {
229
230
  syncStatus?: SyncStatus;
230
231
  syncInfo?: string | undefined;
231
232
  remoteName?: string | undefined;
233
+ // クリーンアップ判定で「未コミット/未プッシュなし」と評価された場合に true
234
+ safeToCleanup?: boolean;
232
235
  }
233
236
 
234
237
  /**
@@ -65,7 +65,8 @@ const iconWidthOverrides: Record<string, number> = {
65
65
  "🚀": 1,
66
66
  "📌": 1,
67
67
  // Worktree status icons
68
- "🟢": 1,
68
+ "🟢": 2,
69
+ "⚪": 2,
69
70
  "🟠": 1,
70
71
  // Change status icons
71
72
  "👉": 1,
@@ -73,7 +74,11 @@ const iconWidthOverrides: Record<string, number> = {
73
74
  "📤": 1,
74
75
  "🔃": 1,
75
76
  "✅": 1,
76
- "⚠️": 1,
77
+ "⚠️": 2,
78
+ "⚠": 1,
79
+ "🛡": 2,
80
+ "☑": 2,
81
+ "☐": 2,
77
82
  // Remote markers
78
83
  "🔗": 1,
79
84
  "💻": 1,
@@ -23,6 +23,7 @@ describe("modelOptions", () => {
23
23
  expect(unique.size).toBe(ids.length);
24
24
  expect(ids).toEqual([
25
25
  "gpt-5.1-codex",
26
+ "gpt-5.2",
26
27
  "gpt-5.1-codex-max",
27
28
  "gpt-5.1-codex-mini",
28
29
  "gpt-5.1",
@@ -36,6 +37,19 @@ describe("modelOptions", () => {
36
37
  expect(getDefaultInferenceForModel(codexMax)).toBe("medium");
37
38
  });
38
39
 
40
+ it("exposes gpt-5.2 with xhigh reasoning and medium default", () => {
41
+ const codex52 = getModelOptions("codex-cli").find(
42
+ (m) => m.id === "gpt-5.2",
43
+ );
44
+ expect(codex52?.inferenceLevels).toEqual([
45
+ "xhigh",
46
+ "high",
47
+ "medium",
48
+ "low",
49
+ ]);
50
+ expect(getDefaultInferenceForModel(codex52)).toBe("medium");
51
+ });
52
+
39
53
  it("lists expected Gemini models", () => {
40
54
  expect(byId("gemini-cli")).toEqual([
41
55
  "gemini-3-pro-preview",
@@ -33,6 +33,13 @@ const MODEL_OPTIONS: Record<string, ModelOption[]> = {
33
33
  defaultInference: "high",
34
34
  isDefault: true,
35
35
  },
36
+ {
37
+ id: "gpt-5.2",
38
+ label: "gpt-5.2",
39
+ description: "Latest frontier model with extra high reasoning",
40
+ inferenceLevels: CODEX_MAX_LEVELS,
41
+ defaultInference: "medium",
42
+ },
36
43
  {
37
44
  id: "gpt-5.1-codex-max",
38
45
  label: "gpt-5.1-codex-max",
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  getTerminalStreams,
34
34
  waitForUserAcknowledgement,
35
35
  } from "./utils/terminal.js";
36
+ import { createLogger } from "./logging/logger.js";
36
37
  import { getToolById, getSharedEnvironment } from "./config/tools.js";
37
38
  import { launchCustomAITool } from "./launcher.js";
38
39
  import { saveSession, loadSession } from "./config/index.js";
@@ -55,6 +56,9 @@ const ERROR_PROMPT = chalk.yellow(
55
56
  "Review the error details, then press Enter to continue.",
56
57
  );
57
58
 
59
+ // Category: cli
60
+ const appLogger = createLogger({ category: "cli" });
61
+
58
62
  async function waitForErrorAcknowledgement(): Promise<void> {
59
63
  await waitForUserAcknowledgement(ERROR_PROMPT);
60
64
  }
@@ -64,14 +68,17 @@ async function waitForErrorAcknowledgement(): Promise<void> {
64
68
  */
65
69
  function printError(message: string): void {
66
70
  console.error(chalk.red(`❌ ${message}`));
71
+ appLogger.error({ message });
67
72
  }
68
73
 
69
74
  function printInfo(message: string): void {
70
75
  console.log(chalk.blue(`ℹ️ ${message}`));
76
+ appLogger.info({ message });
71
77
  }
72
78
 
73
79
  function printWarning(message: string): void {
74
80
  console.warn(chalk.yellow(`⚠️ ${message}`));
81
+ appLogger.warn({ message });
75
82
  }
76
83
 
77
84
  type GitStepResult<T> = { ok: true; value: T } | { ok: false };