@doccy/fell 0.1.4 → 0.2.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 (4) hide show
  1. package/cli.ts +262 -124
  2. package/lib/git.ts +490 -0
  3. package/lib/tui.ts +176 -1
  4. package/package.json +1 -1
package/cli.ts CHANGED
@@ -26,12 +26,16 @@ import {
26
26
  formatBytes,
27
27
  openDirectory,
28
28
  checkGhStatus,
29
+ fetchWorktreeSessionInfo,
30
+ findParentSession,
29
31
  type Worktree,
30
32
  type PrStatus,
31
33
  type FileStatusResult,
32
34
  type FileEntry,
33
35
  type DirSize,
34
36
  type GhDiagnostic,
37
+ type SessionResult,
38
+ type ParentSessionResult,
35
39
  } from "./lib/git"
36
40
  import {
37
41
  c,
@@ -44,6 +48,9 @@ import {
44
48
  shortenPath,
45
49
  formatPrStatus,
46
50
  formatFileStatus,
51
+ formatSessionInfo,
52
+ formatParentSessionInline,
53
+ formatParentSessionExpanded,
47
54
  fellLogo,
48
55
  renderHelpLines,
49
56
  printCliHelp,
@@ -54,18 +61,23 @@ import {
54
61
  // State
55
62
  // ---------------------------------------------------------------------------
56
63
 
64
+ /** In-flight deletion status for an item. Null when not being deleted. */
65
+ type ItemDeleteStatus =
66
+ | { phase: "removing" }
67
+ | { phase: "branch" }
68
+ | { phase: "done"; message: string }
69
+ | { phase: "error"; message: string }
70
+ | { phase: "needs-force" }
71
+
57
72
  interface WorktreeItem {
58
73
  worktree: Worktree
59
74
  prStatus: PrStatus
60
75
  fileStatus: FileStatusResult
61
76
  dirSize: DirSize
62
- }
63
-
64
- /** Per-item progress entry for the deleting status line. */
65
- interface DeleteProgress {
66
- label: string
67
- status: "pending" | "removing" | "branch" | "done" | "error" | "needs-force"
68
- message?: string
77
+ sessionInfo: SessionResult
78
+ parentSession: ParentSessionResult
79
+ /** Set during deletion. Null when the item is not being deleted. */
80
+ deleteStatus: ItemDeleteStatus | null
69
81
  }
70
82
 
71
83
  type Mode =
@@ -73,7 +85,6 @@ type Mode =
73
85
  | { type: "confirm-delete"; indices: number[] }
74
86
  | { type: "confirm-force"; indices: number[]; withBranch: boolean }
75
87
  | { type: "confirm-prune"; candidates: string[] }
76
- | { type: "deleting"; progress: DeleteProgress[]; withBranch: boolean }
77
88
  | { type: "result"; lines: string[] }
78
89
  | { type: "help" }
79
90
 
@@ -138,9 +149,43 @@ function renderRow(
138
149
  index: number,
139
150
  state: State,
140
151
  ): string[] {
152
+ const wt = item.worktree
153
+
154
+ // Items being deleted render as a dimmed single line with progress icon
155
+ if (item.deleteStatus) {
156
+ const branchRaw = wt.branch ?? "(detached)"
157
+ const branchStr = c.dim(truncate(branchRaw, BRANCH_COL))
158
+ const frame = SPINNER_FRAMES[state.spinnerFrame % SPINNER_FRAMES.length]
159
+
160
+ let icon: string
161
+ let detail = ""
162
+ switch (item.deleteStatus.phase) {
163
+ case "removing":
164
+ icon = c.cyan(frame)
165
+ detail = c.dim(" removing")
166
+ break
167
+ case "branch":
168
+ icon = c.cyan(frame)
169
+ detail = c.dim(" deleting branch")
170
+ break
171
+ case "done":
172
+ icon = c.green("\u2713")
173
+ detail = ` ${c.dim(item.deleteStatus.message)}`
174
+ break
175
+ case "error":
176
+ icon = c.red("\u2717")
177
+ detail = ` ${c.dim(item.deleteStatus.message)}`
178
+ break
179
+ case "needs-force":
180
+ icon = c.yellow("!")
181
+ detail = c.dim(" has uncommitted changes")
182
+ break
183
+ }
184
+ return [` ${icon} ${branchStr}${detail}`]
185
+ }
186
+
141
187
  const isFocused = index === state.cursor
142
188
  const isSelected = state.selected.has(index)
143
- const wt = item.worktree
144
189
 
145
190
  const cursor = isFocused ? c.cyan("\u276F") : " "
146
191
  const check = isSelected ? c.lime("\u25CF") : c.dim("\u25CB")
@@ -178,7 +223,11 @@ function renderRow(
178
223
  break
179
224
  }
180
225
 
181
- const mainLine = ` ${cursor} ${check} ${branchPadded}${sha} ${pad(pr, 18)}${sizeStr}${tagStr}`
226
+ // Inline parent session indicator (orange dot on the main row)
227
+ const parentInline = formatParentSessionInline(item.parentSession)
228
+ const parentSuffix = parentInline ? ` ${parentInline}` : ""
229
+
230
+ const mainLine = ` ${cursor} ${check} ${branchPadded}${sha} ${pad(pr, 18)}${sizeStr}${tagStr}${parentSuffix}`
182
231
  const lines = [mainLine]
183
232
 
184
233
  // Sub-line: dirty file status with warning icon, indented under the branch name
@@ -188,9 +237,23 @@ function renderRow(
188
237
  lines.push(` ${fileStatusLine}`)
189
238
  }
190
239
 
191
- // Expanded file list (progressive disclosure via "e" key)
240
+ // Expanded detail (progressive disclosure via "e" key)
192
241
  const MAX_EXPANDED_FILES = 12
242
+ const cols = process.stdout.columns ?? 100
193
243
  if (state.expandedIndex === index) {
244
+ // Session info (only in expanded view)
245
+ const sessionLine = formatSessionInfo(item.sessionInfo, cols - 30)
246
+ if (sessionLine) {
247
+ lines.push(` ${sessionLine}`)
248
+ }
249
+
250
+ // Parent session detail (only in expanded view)
251
+ const parentLines = formatParentSessionExpanded(item.parentSession, cols - 20)
252
+ for (const pl of parentLines) {
253
+ lines.push(` ${pl}`)
254
+ }
255
+
256
+ // File list
194
257
  if (state.expandedFiles === null) {
195
258
  const frame = SPINNER_FRAMES[state.spinnerFrame % SPINNER_FRAMES.length]
196
259
  lines.push(` ${c.dim(`${frame} loading files...`)}`)
@@ -331,49 +394,6 @@ function render(state: State): void {
331
394
  )
332
395
  break
333
396
  }
334
- case "deleting": {
335
- const { progress } = state.mode
336
- const done = progress.filter((p) => p.status === "done").length
337
- const total = progress.length
338
- const frame = SPINNER_FRAMES[state.spinnerFrame % SPINNER_FRAMES.length]
339
-
340
- for (const entry of progress) {
341
- let icon: string
342
- let detail = ""
343
- switch (entry.status) {
344
- case "pending":
345
- icon = c.dim("\u25CB")
346
- break
347
- case "removing":
348
- icon = c.cyan(frame)
349
- detail = c.dim(" removing worktree")
350
- break
351
- case "branch":
352
- icon = c.cyan(frame)
353
- detail = c.dim(" deleting branch")
354
- break
355
- case "done":
356
- icon = c.green("\u2713")
357
- detail = entry.message ? ` ${c.dim(entry.message)}` : ""
358
- break
359
- case "error":
360
- icon = c.red("\u2717")
361
- detail = entry.message ? ` ${c.dim(entry.message)}` : ""
362
- break
363
- case "needs-force":
364
- icon = c.yellow("!")
365
- detail = c.dim(" has uncommitted changes")
366
- break
367
- }
368
- lines.push(` ${icon} ${entry.label}${detail}`)
369
- }
370
-
371
- lines.push("")
372
- lines.push(
373
- ` ${c.cyan(frame)} Deleting ${done}/${total}...`,
374
- )
375
- break
376
- }
377
397
  case "result": {
378
398
  for (const line of state.mode.lines) {
379
399
  lines.push(` ${line}`)
@@ -405,6 +425,12 @@ function render(state: State): void {
405
425
  * and re-rendering after each step), then transitions to "result" mode.
406
426
  * The event loop stays responsive for spinner animation during this time.
407
427
  */
428
+ /**
429
+ * Run delete operations inline within the worktree list.
430
+ * Stays in browse mode -- each item shows its deletion progress directly
431
+ * in its row. Completed items are removed from the list after a brief
432
+ * delay so the user sees the success indicator before it disappears.
433
+ */
408
434
  function startDelete(
409
435
  state: State,
410
436
  indices: number[],
@@ -412,34 +438,26 @@ function startDelete(
412
438
  force: boolean,
413
439
  rerender: () => void,
414
440
  ): void {
415
- // Build progress entries
416
- const progress: DeleteProgress[] = indices.map((idx) => {
417
- const item = state.items[idx]
418
- return {
419
- label: item?.worktree.branch ?? item?.worktree.path ?? `index ${idx}`,
420
- status: "pending" as const,
441
+ // Mark items as deleting. Stay in browse mode so the list stays visible.
442
+ for (const idx of indices) {
443
+ if (state.items[idx]) {
444
+ state.items[idx].deleteStatus = { phase: "removing" }
421
445
  }
422
- })
423
-
424
- state.mode = { type: "deleting", progress, withBranch }
446
+ }
447
+ state.selected.clear()
425
448
  state.message = null
426
449
  rerender()
427
450
 
428
- // Run the actual deletes in a fire-and-forget async block.
429
- // Each step updates progress + re-renders, keeping the TUI alive.
430
451
  ;(async () => {
431
- const resultLines: string[] = []
432
452
  let needsForce = false
433
453
 
434
- for (let i = 0; i < indices.length; i++) {
435
- const idx = indices[i]
454
+ for (const idx of indices) {
436
455
  const item = state.items[idx]
437
456
  if (!item) continue
438
457
  const wt = item.worktree
439
- const entry = progress[i]
440
458
 
441
459
  // Step 1: remove worktree
442
- entry.status = "removing"
460
+ item.deleteStatus = { phase: "removing" }
443
461
  rerender()
444
462
 
445
463
  const removeResult = await removeWorktree(wt.path, force)
@@ -447,24 +465,17 @@ function startDelete(
447
465
  if (removeResult.ok) {
448
466
  // Step 2 (optional): delete branch
449
467
  if (withBranch && wt.branch) {
450
- entry.status = "branch"
468
+ item.deleteStatus = { phase: "branch" }
451
469
  rerender()
452
470
 
453
471
  const branchResult = await deleteBranch(wt.branch, true)
454
472
  if (branchResult.ok) {
455
- entry.status = "done"
456
- entry.message = "worktree + branch removed"
457
- resultLines.push(`${c.green("\u2713")} Removed ${entry.label} + branch`)
473
+ item.deleteStatus = { phase: "done", message: "worktree + branch removed" }
458
474
  } else {
459
- entry.status = "done"
460
- entry.message = `branch: ${branchResult.error}`
461
- resultLines.push(`${c.green("\u2713")} Removed ${entry.label}`)
462
- resultLines.push(`${c.red("\u2717")} Branch ${wt.branch}: ${branchResult.error}`)
475
+ item.deleteStatus = { phase: "done", message: `branch: ${branchResult.error}` }
463
476
  }
464
477
  } else {
465
- entry.status = "done"
466
- entry.message = "removed"
467
- resultLines.push(`${c.green("\u2713")} Removed ${entry.label}`)
478
+ item.deleteStatus = { phase: "done", message: "removed" }
468
479
  }
469
480
  } else {
470
481
  const isUncommitted =
@@ -475,39 +486,48 @@ function startDelete(
475
486
 
476
487
  if (isUncommitted && !force) {
477
488
  needsForce = true
478
- entry.status = "needs-force"
479
- resultLines.push(`${c.yellow("!")} ${entry.label}: has uncommitted changes`)
489
+ item.deleteStatus = { phase: "needs-force" }
480
490
  } else {
481
- entry.status = "error"
482
- entry.message = removeResult.error
483
- resultLines.push(`${c.red("\u2717")} ${entry.label}: ${removeResult.error}`)
491
+ item.deleteStatus = { phase: "error", message: removeResult.error ?? "unknown error" }
484
492
  }
485
493
  }
486
494
 
487
495
  rerender()
488
496
  }
489
497
 
490
- // All items processed. Refresh the worktree list.
491
- await refreshWorktrees(state)
492
- startPrFetching(state, rerender)
493
- startFileStatusFetching(state, rerender)
494
- startSizeFetching(state, rerender)
498
+ // Brief pause so the user can see the final status of each item
499
+ await Bun.sleep(600)
500
+
501
+ // Remove successfully deleted items from the list
502
+ const removedIndices = new Set(
503
+ indices.filter((idx) => state.items[idx]?.deleteStatus?.phase === "done"),
504
+ )
505
+ // Clear delete status on items that weren't removed (errors, needs-force)
506
+ for (const idx of indices) {
507
+ if (state.items[idx] && !removedIndices.has(idx)) {
508
+ state.items[idx].deleteStatus = null
509
+ }
510
+ }
511
+
512
+ if (removedIndices.size > 0) {
513
+ // Rebuild item list without deleted items
514
+ state.items = state.items.filter((_, i) => !removedIndices.has(i))
515
+ state.cursor = Math.min(state.cursor, Math.max(0, state.items.length - 1))
516
+ }
495
517
 
496
518
  if (needsForce) {
497
- state.mode = {
498
- type: "result",
499
- lines: [
500
- ...resultLines,
501
- "",
502
- `${c.yellow("Some worktrees have uncommitted changes.")}`,
503
- `${c.dim("Select them again and press")} ${c.cyan("d")} ${c.dim("to retry with force.")}`,
504
- ],
519
+ state.message = {
520
+ text: "Some worktrees have uncommitted changes. Select and press d to force.",
521
+ kind: "error",
522
+ }
523
+ } else if (removedIndices.size > 0) {
524
+ const n = removedIndices.size
525
+ state.message = {
526
+ text: `${n} worktree${n > 1 ? "s" : ""} removed.`,
527
+ kind: "success",
505
528
  }
506
- } else {
507
- state.mode = { type: "result", lines: resultLines }
508
529
  }
509
530
 
510
- state.message = null
511
531
  rerender()
512
532
  })()
513
533
  }
@@ -525,6 +545,9 @@ async function refreshWorktrees(state: State): Promise<void> {
525
545
  prStatus: { type: "loading" as const },
526
546
  fileStatus: { type: "loading" as const },
527
547
  dirSize: { type: "loading" as const },
548
+ sessionInfo: { type: "loading" as const },
549
+ parentSession: { type: "loading" as const },
550
+ deleteStatus: null,
528
551
  }))
529
552
 
530
553
  state.selected.clear()
@@ -664,6 +687,91 @@ function startFileStatusFetching(state: State, rerender: () => void): void {
664
687
  })()
665
688
  }
666
689
 
690
+ // ---------------------------------------------------------------------------
691
+ // Async session info fetching (background, concurrent)
692
+ // ---------------------------------------------------------------------------
693
+
694
+ const SESSION_CONCURRENCY = 6
695
+
696
+ /**
697
+ * Fetch Claude Code session info for all worktrees in background.
698
+ * Reads local files only (no network), so fast.
699
+ */
700
+ function startSessionFetching(state: State, rerender: () => void): void {
701
+ const entries = state.items.map((item, index) => ({
702
+ path: item.worktree.path,
703
+ index,
704
+ }))
705
+
706
+ const executing: Promise<void>[] = []
707
+
708
+ const fetchOne = async ({ path, index }: { path: string; index: number }) => {
709
+ try {
710
+ const result = await fetchWorktreeSessionInfo(path)
711
+ if (state.items[index]?.worktree.path === path) {
712
+ state.items[index].sessionInfo = result
713
+ }
714
+ } catch {
715
+ if (state.items[index]?.worktree.path === path) {
716
+ state.items[index].sessionInfo = { type: "none" }
717
+ }
718
+ }
719
+ rerender()
720
+ }
721
+
722
+ ;(async () => {
723
+ for (const entry of entries) {
724
+ const task = fetchOne(entry)
725
+ executing.push(task)
726
+ task.then(() => {
727
+ const idx = executing.indexOf(task)
728
+ if (idx !== -1) executing.splice(idx, 1)
729
+ })
730
+
731
+ if (executing.length >= SESSION_CONCURRENCY) {
732
+ await Promise.race(executing)
733
+ }
734
+ }
735
+ await Promise.all(executing)
736
+ })()
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Async parent session detection (background)
741
+ // ---------------------------------------------------------------------------
742
+
743
+ /**
744
+ * For each worktree, find the active Claude Code session that created it.
745
+ * Runs findParentSession() for each worktree concurrently.
746
+ * Since this involves grepping JSONL files it's the slowest fetch --
747
+ * runs after the faster fetches have already populated the UI.
748
+ */
749
+ function startParentSessionFetching(state: State, rerender: () => void): void {
750
+ const entries = state.items.map((item, index) => ({
751
+ path: item.worktree.path,
752
+ index,
753
+ }))
754
+
755
+ // Run all concurrently -- findParentSession already limits grep parallelism internally
756
+ ;(async () => {
757
+ await Promise.all(
758
+ entries.map(async ({ path, index }) => {
759
+ try {
760
+ const result = await findParentSession(path)
761
+ if (state.items[index]?.worktree.path === path) {
762
+ state.items[index].parentSession = result
763
+ }
764
+ } catch {
765
+ if (state.items[index]?.worktree.path === path) {
766
+ state.items[index].parentSession = { type: "none" }
767
+ }
768
+ }
769
+ rerender()
770
+ }),
771
+ )
772
+ })()
773
+ }
774
+
667
775
  // ---------------------------------------------------------------------------
668
776
  // Async directory size fetching (background, sequential)
669
777
  // ---------------------------------------------------------------------------
@@ -701,24 +809,29 @@ function startSizeFetching(state: State, rerender: () => void): void {
701
809
  async function handleBrowseKey(state: State, key: Key): Promise<void> {
702
810
  const ch = keyChar(key)
703
811
 
704
- // Navigation (collapse expand on cursor move)
812
+ // Navigation (collapse expand on cursor move, skip items being deleted)
705
813
  if (key === "up" || ch === "k") {
706
- state.cursor = Math.max(0, state.cursor - 1)
814
+ let next = state.cursor - 1
815
+ while (next >= 0 && state.items[next]?.deleteStatus) next--
816
+ if (next >= 0) state.cursor = next
707
817
  state.expandedIndex = null
708
818
  state.expandedFiles = null
709
819
  state.message = null
710
820
  return
711
821
  }
712
822
  if (key === "down" || ch === "j") {
713
- state.cursor = Math.min(state.items.length - 1, state.cursor + 1)
823
+ let next = state.cursor + 1
824
+ while (next < state.items.length && state.items[next]?.deleteStatus) next++
825
+ if (next < state.items.length) state.cursor = next
714
826
  state.expandedIndex = null
715
827
  state.expandedFiles = null
716
828
  state.message = null
717
829
  return
718
830
  }
719
831
 
720
- // Selection
832
+ // Selection (skip items being deleted)
721
833
  if (key === "space") {
834
+ if (state.items[state.cursor]?.deleteStatus) return
722
835
  if (state.selected.has(state.cursor)) {
723
836
  state.selected.delete(state.cursor)
724
837
  } else {
@@ -793,6 +906,8 @@ async function handleBrowseKey(state: State, key: Key): Promise<void> {
793
906
  await refreshWorktrees(state)
794
907
  startPrFetching(state, () => render(state))
795
908
  startFileStatusFetching(state, () => render(state))
909
+ startSessionFetching(state, () => render(state))
910
+ startParentSessionFetching(state, () => render(state))
796
911
  startSizeFetching(state, () => render(state))
797
912
  state.message = { text: "Refreshed.", kind: "success" }
798
913
  return
@@ -900,6 +1015,8 @@ async function handleConfirmPruneKey(state: State, key: Key): Promise<void> {
900
1015
  await refreshWorktrees(state)
901
1016
  startPrFetching(state, () => render(state))
902
1017
  startFileStatusFetching(state, () => render(state))
1018
+ startSessionFetching(state, () => render(state))
1019
+ startParentSessionFetching(state, () => render(state))
903
1020
  startSizeFetching(state, () => render(state))
904
1021
  state.mode = {
905
1022
  type: "result",
@@ -933,13 +1050,21 @@ async function printListAndExit(): Promise<void> {
933
1050
  console.log(` ${c.bold(fellLogo())} ${c.dim("--list")}`)
934
1051
  console.log()
935
1052
 
936
- // Fetch file statuses for all worktrees concurrently
937
- const fileStatuses = await Promise.all(
938
- worktrees.map(async (wt) => {
939
- if (wt.isBare) return { type: "clean" as const }
940
- return fetchWorktreeFileStatus(wt.path)
941
- }),
942
- )
1053
+ // Fetch file statuses, session info, and parent sessions concurrently
1054
+ const [fileStatuses, sessionInfos, parentSessions] = await Promise.all([
1055
+ Promise.all(
1056
+ worktrees.map(async (wt) => {
1057
+ if (wt.isBare) return { type: "clean" as const }
1058
+ return fetchWorktreeFileStatus(wt.path)
1059
+ }),
1060
+ ),
1061
+ Promise.all(
1062
+ worktrees.map(async (wt) => fetchWorktreeSessionInfo(wt.path)),
1063
+ ),
1064
+ Promise.all(
1065
+ worktrees.map(async (wt) => findParentSession(wt.path)),
1066
+ ),
1067
+ ])
943
1068
 
944
1069
  for (let i = 0; i < worktrees.length; i++) {
945
1070
  const wt = worktrees[i]
@@ -955,8 +1080,12 @@ async function printListAndExit(): Promise<void> {
955
1080
  let path = wt.path
956
1081
  if (home && path.startsWith(home)) path = "~" + path.slice(home.length)
957
1082
 
1083
+ // Inline parent session indicator (orange dot)
1084
+ const parentInline = formatParentSessionInline(parentSessions[i])
1085
+ const parentSuffix = parentInline ? ` ${parentInline}` : ""
1086
+
958
1087
  console.log(
959
- ` ${branch.padEnd(35)} ${c.dim(sha)} ${c.dim(path)}${tagStr}`,
1088
+ ` ${branch.padEnd(35)} ${c.dim(sha)} ${c.dim(path)}${tagStr}${parentSuffix}`,
960
1089
  )
961
1090
 
962
1091
  // Sub-line for dirty file status
@@ -964,6 +1093,17 @@ async function printListAndExit(): Promise<void> {
964
1093
  if (fsLine) {
965
1094
  console.log(` ${fsLine}`)
966
1095
  }
1096
+
1097
+ // Sub-lines for session info + parent session detail (--list shows expanded by default)
1098
+ const cols = process.stdout.columns ?? 100
1099
+ const sessLine = formatSessionInfo(sessionInfos[i], cols - 20)
1100
+ if (sessLine) {
1101
+ console.log(` ${sessLine}`)
1102
+ }
1103
+ const parentLines = formatParentSessionExpanded(parentSessions[i], cols - 20)
1104
+ for (const pl of parentLines) {
1105
+ console.log(` ${pl}`)
1106
+ }
967
1107
  }
968
1108
 
969
1109
  // Fetch PR statuses if gh is available
@@ -1027,8 +1167,8 @@ function startSpinnerTimer(
1027
1167
  const hasLoading = state.items.some(
1028
1168
  (i) => i.prStatus.type === "loading" || i.dirSize.type === "loading",
1029
1169
  )
1030
- const isDeleting = state.mode.type === "deleting"
1031
- if (hasLoading || isDeleting) {
1170
+ const hasDeleting = state.items.some((i) => i.deleteStatus !== null)
1171
+ if (hasLoading || hasDeleting) {
1032
1172
  state.spinnerFrame =
1033
1173
  (state.spinnerFrame + 1) % SPINNER_FRAMES.length
1034
1174
  rerender()
@@ -1085,6 +1225,9 @@ async function main() {
1085
1225
  prStatus: { type: "loading" },
1086
1226
  fileStatus: { type: "loading" },
1087
1227
  dirSize: { type: "loading" },
1228
+ sessionInfo: { type: "loading" },
1229
+ parentSession: { type: "loading" },
1230
+ deleteStatus: null,
1088
1231
  })),
1089
1232
  mainWorktree,
1090
1233
  cursor: 0,
@@ -1134,9 +1277,11 @@ async function main() {
1134
1277
  // Initial render
1135
1278
  render(state)
1136
1279
 
1137
- // Start background PR fetching, file status checks, and size estimation
1280
+ // Start background fetching: PR, file status, sessions, parent sessions, size
1138
1281
  startPrFetching(state, () => render(state))
1139
1282
  startFileStatusFetching(state, () => render(state))
1283
+ startSessionFetching(state, () => render(state))
1284
+ startParentSessionFetching(state, () => render(state))
1140
1285
  startSizeFetching(state, () => render(state))
1141
1286
 
1142
1287
  // Start spinner animation timer
@@ -1169,13 +1314,6 @@ async function main() {
1169
1314
  await handleConfirmPruneKey(state, key)
1170
1315
  break
1171
1316
 
1172
- case "deleting":
1173
- // Background delete in progress - ignore keys (spinner keeps animating via timer)
1174
- if (key === "ctrl-c" || key === "escape" || keyChar(key) === "q") {
1175
- state.shouldQuit = true
1176
- }
1177
- break
1178
-
1179
1317
  case "result":
1180
1318
  // Any key returns to browse
1181
1319
  state.mode = { type: "browse" }
package/lib/git.ts CHANGED
@@ -435,3 +435,493 @@ export async function fetchPrForBranch(
435
435
  return null
436
436
  }
437
437
  }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Claude Code session lookups
441
+ // ---------------------------------------------------------------------------
442
+
443
+ export interface SessionInfo {
444
+ sessionCount: number
445
+ latestSessionId: string
446
+ /** First user message content from the most recent session, truncated. */
447
+ latestPrompt: string
448
+ /** ISO timestamp from the first user message. */
449
+ latestTimestamp: string
450
+ }
451
+
452
+ export type SessionResult =
453
+ | { type: "loading" }
454
+ | { type: "found"; info: SessionInfo }
455
+ | { type: "none" }
456
+
457
+ /**
458
+ * Encode an absolute path into Claude Code's project directory name.
459
+ * Claude replaces all non-alphanumeric characters with `-`,
460
+ * so `/Users/x/.claude/worktrees/foo` becomes
461
+ * `-Users-x--claude-worktrees-foo` (`.` and `/` both become `-`).
462
+ */
463
+ function encodeClaudeProjectPath(absolutePath: string): string {
464
+ return absolutePath.replace(/[^a-zA-Z0-9]/g, "-")
465
+ }
466
+
467
+ /**
468
+ * Extract the first user prompt from a Claude Code session JSONL file.
469
+ * Reads only the first ~15 lines for performance -- the user message
470
+ * is almost always within the first few entries.
471
+ */
472
+ async function extractFirstPrompt(
473
+ jsonlPath: string,
474
+ ): Promise<{ prompt: string; timestamp: string } | null> {
475
+ try {
476
+ const content = await Bun.file(jsonlPath).text()
477
+ const lines = content.split("\n").slice(0, 15)
478
+
479
+ for (const line of lines) {
480
+ if (!line.trim()) continue
481
+ try {
482
+ const entry = JSON.parse(line)
483
+ if (entry.type !== "user") continue
484
+
485
+ const rawContent = entry.message?.content
486
+ let text = ""
487
+ if (typeof rawContent === "string") {
488
+ text = rawContent
489
+ } else if (Array.isArray(rawContent)) {
490
+ // Array of content blocks: [{ type: "text", text: "..." }, ...]
491
+ const textBlock = rawContent.find(
492
+ (b: { type: string }) => b.type === "text",
493
+ )
494
+ text = textBlock?.text ?? ""
495
+ }
496
+
497
+ // Strip XML-like tags (e.g. <local-command-caveat>...) that aren't real prompts
498
+ if (text.startsWith("<")) {
499
+ // Try to find actual text after tags
500
+ const stripped = text.replace(/<[^>]+>[^<]*<\/[^>]+>/g, "").trim()
501
+ if (stripped) text = stripped
502
+ else continue // skip entries that are purely XML tags
503
+ }
504
+
505
+ // Collapse whitespace/newlines into single spaces for display
506
+ text = text.trim().replace(/\s+/g, " ")
507
+ if (!text) continue
508
+
509
+ return {
510
+ prompt: text,
511
+ timestamp: entry.timestamp ?? "",
512
+ }
513
+ } catch {
514
+ // Skip malformed lines
515
+ }
516
+ }
517
+ } catch {
518
+ // File read failed
519
+ }
520
+ return null
521
+ }
522
+
523
+ /**
524
+ * Extract the `cwd` from the first parseable line of a JSONL session file.
525
+ * Used by the global scanner to recover the original absolute path
526
+ * without decoding the project directory name (which is lossy).
527
+ */
528
+ async function extractCwd(jsonlPath: string): Promise<string | null> {
529
+ try {
530
+ const content = await Bun.file(jsonlPath).text()
531
+ for (const line of content.split("\n").slice(0, 10)) {
532
+ if (!line.trim()) continue
533
+ try {
534
+ const entry = JSON.parse(line)
535
+ if (entry.cwd) return entry.cwd
536
+ } catch {
537
+ /* skip malformed */
538
+ }
539
+ }
540
+ } catch {
541
+ /* file read failed */
542
+ }
543
+ return null
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Global session scanner
548
+ // ---------------------------------------------------------------------------
549
+
550
+ /** A single project directory from ~/.claude/projects/ with session metadata. */
551
+ export interface GlobalProjectEntry {
552
+ /** The original absolute working directory (extracted from JSONL cwd field). */
553
+ cwd: string
554
+ /** Whether this is a Claude Code worktree (path contains .claude/worktrees/). */
555
+ isWorktree: boolean
556
+ /** The repo root (path before /.claude/worktrees/, or the cwd itself for main repos). */
557
+ repoRoot: string
558
+ /** Worktree name (segment after .claude/worktrees/), null for main repo sessions. */
559
+ worktreeName: string | null
560
+ /** Number of session JSONL files. */
561
+ sessionCount: number
562
+ /** First user prompt from the most recent session. */
563
+ latestPrompt: string
564
+ /** ISO timestamp from the most recent session's first user message. */
565
+ latestTimestamp: string
566
+ }
567
+
568
+ /** Sessions grouped by repo root. */
569
+ export interface GlobalRepoGroup {
570
+ repoRoot: string
571
+ /** Session entry for the main repo (non-worktree), if any. */
572
+ main: GlobalProjectEntry | null
573
+ /** Worktree session entries. */
574
+ worktrees: GlobalProjectEntry[]
575
+ /** Total sessions across main + all worktrees. */
576
+ totalSessions: number
577
+ }
578
+
579
+ /**
580
+ * Scan ~/.claude/projects/ for all Claude Code sessions across all repos.
581
+ * Groups results by repo root. Excludes the specified repo (usually the
582
+ * current repo, which is already shown in the main worktree list).
583
+ *
584
+ * Returns empty array if ~/.claude doesn't exist or has no sessions.
585
+ * Never throws.
586
+ */
587
+ export async function listGlobalClaudeSessions(
588
+ excludeRepoRoot?: string,
589
+ ): Promise<GlobalRepoGroup[]> {
590
+ const home = process.env.HOME
591
+ if (!home) return []
592
+
593
+ const projectsDir = `${home}/.claude/projects`
594
+
595
+ try {
596
+ const { readdir, stat } = await import("node:fs/promises")
597
+ const dirs = await readdir(projectsDir).catch(() => null)
598
+ if (!dirs || dirs.length === 0) return []
599
+
600
+ // Process each project directory concurrently
601
+ const entries: GlobalProjectEntry[] = []
602
+ const CONCURRENCY = 8
603
+ const executing: Promise<void>[] = []
604
+
605
+ const processDir = async (dirName: string) => {
606
+ const dirPath = `${projectsDir}/${dirName}`
607
+
608
+ // List JSONL files
609
+ const files = await readdir(dirPath).catch(() => null)
610
+ if (!files) return
611
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"))
612
+ if (jsonlFiles.length === 0) return
613
+
614
+ // Find most recent JSONL by mtime
615
+ const withMtime = await Promise.all(
616
+ jsonlFiles.map(async (f) => {
617
+ const s = await stat(`${dirPath}/${f}`).catch(() => null)
618
+ return { file: f, mtime: s?.mtimeMs ?? 0 }
619
+ }),
620
+ )
621
+ withMtime.sort((a, b) => b.mtime - a.mtime)
622
+ const latestFile = withMtime[0].file
623
+
624
+ // Extract cwd from the most recent session to get the real path
625
+ const cwd = await extractCwd(`${dirPath}/${latestFile}`)
626
+ if (!cwd) return
627
+
628
+ // Extract prompt from the most recent session
629
+ const promptInfo = await extractFirstPrompt(`${dirPath}/${latestFile}`)
630
+
631
+ // Determine if this is a worktree session
632
+ const claudeWtMatch = cwd.match(/^(.+)\/.claude\/worktrees\/([^/]+)/)
633
+ const cursorWtMatch = cwd.match(
634
+ /^(.+)\/.cursor\/worktrees\/[^/]+\/([^/]+)/,
635
+ )
636
+ const wtMatch = claudeWtMatch ?? cursorWtMatch
637
+
638
+ entries.push({
639
+ cwd,
640
+ isWorktree: !!wtMatch,
641
+ repoRoot: wtMatch ? wtMatch[1] : cwd,
642
+ worktreeName: wtMatch ? wtMatch[2] : null,
643
+ sessionCount: jsonlFiles.length,
644
+ latestPrompt: promptInfo?.prompt ?? "",
645
+ latestTimestamp: promptInfo?.timestamp ?? "",
646
+ })
647
+ }
648
+
649
+ // Process with concurrency limit
650
+ for (const dirName of dirs) {
651
+ const task = processDir(dirName)
652
+ executing.push(task)
653
+ task.then(() => {
654
+ const idx = executing.indexOf(task)
655
+ if (idx !== -1) executing.splice(idx, 1)
656
+ })
657
+ if (executing.length >= CONCURRENCY) {
658
+ await Promise.race(executing)
659
+ }
660
+ }
661
+ await Promise.all(executing)
662
+
663
+ // Group by repo root
664
+ const groups = new Map<string, GlobalRepoGroup>()
665
+
666
+ for (const entry of entries) {
667
+ // Skip the excluded repo
668
+ if (excludeRepoRoot && entry.repoRoot === excludeRepoRoot) continue
669
+
670
+ let group = groups.get(entry.repoRoot)
671
+ if (!group) {
672
+ group = { repoRoot: entry.repoRoot, main: null, worktrees: [], totalSessions: 0 }
673
+ groups.set(entry.repoRoot, group)
674
+ }
675
+
676
+ group.totalSessions += entry.sessionCount
677
+
678
+ if (entry.isWorktree) {
679
+ group.worktrees.push(entry)
680
+ } else {
681
+ group.main = entry
682
+ }
683
+ }
684
+
685
+ // Sort groups by total session count descending
686
+ const result = Array.from(groups.values())
687
+ result.sort((a, b) => b.totalSessions - a.totalSessions)
688
+
689
+ // Sort worktrees within each group by session count descending
690
+ for (const group of result) {
691
+ group.worktrees.sort((a, b) => b.sessionCount - a.sessionCount)
692
+ }
693
+
694
+ return result
695
+ } catch {
696
+ return []
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Look up Claude Code session info for a worktree by reading
702
+ * `~/.claude/projects/{encoded-path}/` directly.
703
+ *
704
+ * Assumption: Claude Code encodes the worktree CWD by replacing `/` with `-`
705
+ * to form the project directory name. Each `{uuid}.jsonl` file inside is a
706
+ * session transcript. This is an undocumented internal format and may change.
707
+ */
708
+ export async function fetchWorktreeSessionInfo(
709
+ worktreePath: string,
710
+ ): Promise<SessionResult> {
711
+ const home = process.env.HOME
712
+ if (!home) return { type: "none" }
713
+
714
+ const encoded = encodeClaudeProjectPath(worktreePath)
715
+ const projectDir = `${home}/.claude/projects/${encoded}`
716
+
717
+ try {
718
+ const { readdir, stat } = await import("node:fs/promises")
719
+ const entries = await readdir(projectDir).catch(() => null)
720
+ if (!entries) return { type: "none" }
721
+
722
+ // Find all .jsonl files (each is a session)
723
+ const jsonlFiles = entries.filter((e) => e.endsWith(".jsonl"))
724
+ if (jsonlFiles.length === 0) return { type: "none" }
725
+
726
+ // Sort by mtime descending to find the most recent session
727
+ const withMtime = await Promise.all(
728
+ jsonlFiles.map(async (f) => {
729
+ const s = await stat(`${projectDir}/${f}`).catch(() => null)
730
+ return { file: f, mtime: s?.mtimeMs ?? 0 }
731
+ }),
732
+ )
733
+ withMtime.sort((a, b) => b.mtime - a.mtime)
734
+
735
+ const latestFile = withMtime[0].file
736
+ const latestSessionId = latestFile.replace(".jsonl", "")
737
+
738
+ // Extract first user prompt from the most recent session
739
+ const promptInfo = await extractFirstPrompt(
740
+ `${projectDir}/${latestFile}`,
741
+ )
742
+
743
+ return {
744
+ type: "found",
745
+ info: {
746
+ sessionCount: jsonlFiles.length,
747
+ latestSessionId,
748
+ latestPrompt: promptInfo?.prompt ?? "",
749
+ latestTimestamp: promptInfo?.timestamp ?? "",
750
+ },
751
+ }
752
+ } catch {
753
+ return { type: "none" }
754
+ }
755
+ }
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // Parent session association (worktree <-> spawning session)
759
+ // ---------------------------------------------------------------------------
760
+
761
+ /*
762
+ * FLOW DIAGRAM: How we match a worktree to the Claude Code session that created it
763
+ *
764
+ * ~/.claude/sessions/
765
+ * ├── 15746.json ─────────┐
766
+ * ├── 51087.json │ Step 1: List active session PIDs
767
+ * └── 71924.json │ Read each JSON for { sessionId, cwd }
768
+ * │
769
+ * ▼
770
+ * ┌─────────────────────────────────────────┐
771
+ * │ Active Session Registry │
772
+ * │ │
773
+ * │ PID 15746 │
774
+ * │ sessionId: 4491a274-... │
775
+ * │ cwd: /Users/x/code/medlo │
776
+ * │ │
777
+ * │ PID 51087 │
778
+ * │ sessionId: e3408292-... │
779
+ * │ cwd: /Users/x/code/medlo/ │
780
+ * │ .claude/worktrees/evo │
781
+ * └──────────────┬──────────────────────────┘
782
+ * │
783
+ * │ Step 2: For each session, locate its JSONL transcript
784
+ * │ at ~/.claude/projects/{encode(cwd)}/{sessionId}.jsonl
785
+ * ▼
786
+ * ~/.claude/projects/-Users-x-code-medlo/
787
+ * └── 4491a274-....jsonl ◄── Full conversation transcript (JSONL)
788
+ * │
789
+ * │ Step 3: grep the JSONL for each worktree path
790
+ * │ e.g. ".worktrees/session-column"
791
+ * │ (matches Bash tool calls like
792
+ * │ `git worktree add .worktrees/session-column`)
793
+ * ▼
794
+ * ┌─────────────────────────────────────────┐
795
+ * │ MATCH FOUND │
796
+ * │ │
797
+ * │ Session 4491a274 (cwd: ~/code/medlo) │
798
+ * │ contains ".worktrees/session-column" │
799
+ * │ in its transcript │
800
+ * │ │
801
+ * │ → This session CREATED the worktree │
802
+ * └─────────────────────────────────────────┘
803
+ *
804
+ * WHY only active sessions:
805
+ * - ~/.claude/sessions/ only contains PIDs of RUNNING Claude Code processes
806
+ * - Completed sessions are removed from this registry
807
+ * - Scanning all historical sessions (121+ files, 185MB+) would be too slow
808
+ * - Active sessions are the most useful: "who is working in this worktree right now?"
809
+ *
810
+ * WHY grep instead of JSON parsing:
811
+ * - JSONL files can be 5MB+ per session (this conversation alone is ~5.5MB)
812
+ * - grep -l short-circuits on first match (doesn't read the whole file)
813
+ * - We only need to know IF the path appears, not WHERE or HOW
814
+ * - Bun's $ shell handles the subprocess efficiently
815
+ *
816
+ * PERFORMANCE:
817
+ * - Typically 5-15 active sessions
818
+ * - Each grep -l takes ~50-400ms depending on file size
819
+ * - Total: <2s for all active sessions, runs in background
820
+ * - Falls back gracefully if ~/.claude doesn't exist
821
+ */
822
+
823
+ /** A Claude Code session that spawned or references a worktree. */
824
+ export interface ParentSession {
825
+ /** The session UUID. */
826
+ sessionId: string
827
+ /** The working directory of the session (typically the main repo, not the worktree). */
828
+ cwd: string
829
+ /** First user prompt from the session, for identification. */
830
+ prompt: string
831
+ }
832
+
833
+ export type ParentSessionResult =
834
+ | { type: "loading" }
835
+ | { type: "found"; session: ParentSession }
836
+ | { type: "none" }
837
+
838
+ /**
839
+ * Find the active Claude Code session that created or references a worktree.
840
+ *
841
+ * Scans `~/.claude/sessions/*.json` for running sessions, then greps each
842
+ * session's JSONL transcript for the worktree path. Returns the first match.
843
+ *
844
+ * @param worktreePath - Absolute path to the worktree directory
845
+ * @returns The parent session if found, or `{ type: "none" }` if no active
846
+ * session references this worktree. Never throws.
847
+ */
848
+ export async function findParentSession(
849
+ worktreePath: string,
850
+ ): Promise<ParentSessionResult> {
851
+ const home = process.env.HOME
852
+ if (!home) return { type: "none" }
853
+
854
+ const sessionsDir = `${home}/.claude/sessions`
855
+ const projectsDir = `${home}/.claude/projects`
856
+
857
+ // Extract just the worktree-specific suffix for grep matching.
858
+ // e.g. "/Users/x/code/fell/.worktrees/session-column" -> ".worktrees/session-column"
859
+ // This is more robust than grepping the full absolute path, which may appear
860
+ // in different forms (with/without trailing slash, relative, etc.)
861
+ const wtSuffix = worktreePath.match(/\.worktrees\/[^/]+$/)?.[0]
862
+ ?? worktreePath.match(/\.claude\/worktrees\/[^/]+$/)?.[0]
863
+ if (!wtSuffix) return { type: "none" }
864
+
865
+ try {
866
+ const { readdir } = await import("node:fs/promises")
867
+ const sessionFiles = await readdir(sessionsDir).catch(() => null)
868
+ if (!sessionFiles) return { type: "none" }
869
+
870
+ // Step 1: Read all active session metadata
871
+ const activeSessions: Array<{
872
+ sessionId: string
873
+ cwd: string
874
+ jsonlPath: string
875
+ }> = []
876
+
877
+ for (const file of sessionFiles) {
878
+ if (!file.endsWith(".json")) continue
879
+ try {
880
+ const raw = await Bun.file(`${sessionsDir}/${file}`).json()
881
+ const sid = raw?.sessionId as string | undefined
882
+ const cwd = raw?.cwd as string | undefined
883
+ if (!sid || !cwd) continue
884
+
885
+ const encoded = encodeClaudeProjectPath(cwd)
886
+ const jsonlPath = `${projectsDir}/${encoded}/${sid}.jsonl`
887
+
888
+ // Only include if the JSONL file exists
889
+ if (await Bun.file(jsonlPath).exists()) {
890
+ activeSessions.push({ sessionId: sid, cwd, jsonlPath })
891
+ }
892
+ } catch {
893
+ /* skip unreadable session files */
894
+ }
895
+ }
896
+
897
+ if (activeSessions.length === 0) return { type: "none" }
898
+
899
+ // Step 2: grep each session's JSONL for the worktree path.
900
+ // Run concurrently -- each grep -l short-circuits on first match.
901
+ const grepResults = await Promise.all(
902
+ activeSessions.map(async (session) => {
903
+ const result = await $`grep -l ${wtSuffix} ${session.jsonlPath}`
904
+ .nothrow()
905
+ .quiet()
906
+ return { session, matched: result.exitCode === 0 }
907
+ }),
908
+ )
909
+
910
+ const match = grepResults.find((r) => r.matched)
911
+ if (!match) return { type: "none" }
912
+
913
+ // Step 3: Extract the first user prompt from the matched session for display
914
+ const promptInfo = await extractFirstPrompt(match.session.jsonlPath)
915
+
916
+ return {
917
+ type: "found",
918
+ session: {
919
+ sessionId: match.session.sessionId,
920
+ cwd: match.session.cwd,
921
+ prompt: promptInfo?.prompt ?? "",
922
+ },
923
+ }
924
+ } catch {
925
+ return { type: "none" }
926
+ }
927
+ }
package/lib/tui.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * and the help screen content.
5
5
  */
6
6
 
7
- import type { PrStatus, FileStatusResult } from "./git"
7
+ import type { PrStatus, FileStatusResult, SessionResult, GlobalRepoGroup, ParentSessionResult } from "./git"
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // ANSI escape helpers
@@ -237,6 +237,181 @@ export function formatFileStatus(result: FileStatusResult): string | null {
237
237
  return `${c.yellow("\u26A0")} ${parts.join(c.dim(" \u00B7 "))}`
238
238
  }
239
239
 
240
+ // ---------------------------------------------------------------------------
241
+ // Session info formatting
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Render a Claude Code session sub-line for a worktree.
246
+ * Returns null if no sessions or still loading.
247
+ */
248
+ export function formatSessionInfo(
249
+ result: SessionResult,
250
+ maxPromptWidth: number,
251
+ ): string | null {
252
+ if (result.type !== "found") return null
253
+
254
+ const { sessionCount, latestPrompt } = result.info
255
+ const s = sessionCount === 1 ? "session" : "sessions"
256
+ const count = c.dim(`${sessionCount} ${s}`)
257
+
258
+ if (!latestPrompt) {
259
+ return `${c.dim("\u25C8")} ${count}`
260
+ }
261
+
262
+ const prompt = truncate(latestPrompt, maxPromptWidth)
263
+ return `${c.dim("\u25C8")} ${count} ${c.dim("\u00B7")} ${c.dim(c.italic(`"${prompt}"`))}`
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Parent session formatting
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /** Orange 256-colour code for Claude/session indicators. */
271
+ const orange = (s: string) => `\x1b[38;5;208m${s}\x1b[0m`
272
+
273
+ /**
274
+ * Render an inline orange dot indicator for worktrees with an active parent session.
275
+ * Shown on the main row (not a sub-line). Returns empty string if no parent session.
276
+ */
277
+ export function formatParentSessionInline(result: ParentSessionResult): string {
278
+ if (result.type !== "found") return ""
279
+ return orange("\u25CF") + " " + c.dim("session")
280
+ }
281
+
282
+ /**
283
+ * Render expanded parent session detail lines (shown when user presses "e").
284
+ * Includes the parent session's CWD and prompt summary.
285
+ */
286
+ export function formatParentSessionExpanded(
287
+ result: ParentSessionResult,
288
+ maxWidth: number,
289
+ ): string[] {
290
+ if (result.type !== "found") return []
291
+
292
+ const { cwd, prompt } = result.session
293
+ const home = process.env.HOME ?? ""
294
+ let cwdDisplay = cwd
295
+ if (home && cwdDisplay.startsWith(home)) {
296
+ cwdDisplay = "~" + cwdDisplay.slice(home.length)
297
+ }
298
+
299
+ const lines: string[] = []
300
+ const label = `${orange("\u25CF")} ${c.dim("session")} ${c.dim("\u00B7")} ${c.dim(truncate(cwdDisplay, 35))}`
301
+
302
+ if (prompt) {
303
+ lines.push(`${label} ${c.dim("\u00B7")} ${c.dim(c.italic(`"${truncate(prompt, maxWidth - 50)}"`))}`)
304
+ } else {
305
+ lines.push(label)
306
+ }
307
+
308
+ return lines
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Global session rendering
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /**
316
+ * Render the "other repos" global session section for the TUI.
317
+ * Shows a compact summary of sessions grouped by repo.
318
+ */
319
+ export function renderGlobalSessionLines(
320
+ groups: GlobalRepoGroup[],
321
+ maxWidth: number,
322
+ ): string[] {
323
+ if (groups.length === 0) return []
324
+
325
+ const totalRepos = groups.length
326
+ const totalWorktrees = groups.reduce((sum, g) => sum + g.worktrees.length, 0)
327
+ const totalSessions = groups.reduce((sum, g) => sum + g.totalSessions, 0)
328
+
329
+ const lines: string[] = []
330
+ lines.push("")
331
+ lines.push(
332
+ ` ${c.dim(`${totalRepos} other repo${totalRepos === 1 ? "" : "s"}`)} ${c.dim("\u00B7")} ${c.dim(`${totalWorktrees} worktree${totalWorktrees === 1 ? "" : "s"}`)} ${c.dim("\u00B7")} ${c.dim(`${totalSessions} session${totalSessions === 1 ? "" : "s"}`)}`,
333
+ )
334
+
335
+ for (const group of groups) {
336
+ const home = process.env.HOME ?? ""
337
+ let repoDisplay = group.repoRoot
338
+ if (home && repoDisplay.startsWith(home)) {
339
+ repoDisplay = "~" + repoDisplay.slice(home.length)
340
+ }
341
+
342
+ const wtCount = group.worktrees.length
343
+ const sessCount = group.totalSessions
344
+
345
+ lines.push(
346
+ ` ${c.dim(truncate(repoDisplay, maxWidth - 30))} ${c.dim(`${wtCount} wt`)} ${c.dim("\u00B7")} ${c.dim(`${sessCount} sess`)}`,
347
+ )
348
+
349
+ // Show worktrees with their latest prompt (compact)
350
+ for (const wt of group.worktrees.slice(0, 3)) {
351
+ const name = wt.worktreeName ?? "main"
352
+ const prompt = wt.latestPrompt
353
+ ? ` ${c.dim("\u00B7")} ${c.dim(c.italic(`"${truncate(wt.latestPrompt, maxWidth - 40)}"`))}`
354
+ : ""
355
+ lines.push(
356
+ ` ${c.dim("\u25C8")} ${c.dim(name)}${prompt}`,
357
+ )
358
+ }
359
+ if (group.worktrees.length > 3) {
360
+ lines.push(` ${c.dim(`... ${group.worktrees.length - 3} more`)}`)
361
+ }
362
+ }
363
+
364
+ return lines
365
+ }
366
+
367
+ /**
368
+ * Render global sessions for --list mode (more expanded than TUI).
369
+ */
370
+ export function printGlobalSessions(groups: GlobalRepoGroup[]): void {
371
+ if (groups.length === 0) return
372
+
373
+ console.log()
374
+ console.log(` ${c.dim("OTHER REPOS")}`)
375
+ console.log()
376
+
377
+ for (const group of groups) {
378
+ const home = process.env.HOME ?? ""
379
+ let repoDisplay = group.repoRoot
380
+ if (home && repoDisplay.startsWith(home)) {
381
+ repoDisplay = "~" + repoDisplay.slice(home.length)
382
+ }
383
+
384
+ const parts: string[] = []
385
+ if (group.worktrees.length > 0) {
386
+ parts.push(`${group.worktrees.length} worktree${group.worktrees.length === 1 ? "" : "s"}`)
387
+ }
388
+ parts.push(`${group.totalSessions} session${group.totalSessions === 1 ? "" : "s"}`)
389
+
390
+ console.log(` ${c.dim(repoDisplay)} ${c.dim(`(${parts.join(", ")})`)}`)
391
+
392
+ // Main repo sessions
393
+ if (group.main && group.main.sessionCount > 0) {
394
+ const prompt = group.main.latestPrompt
395
+ ? ` ${c.dim(c.italic(`"${truncate(group.main.latestPrompt, 60)}"`))}`
396
+ : ""
397
+ console.log(
398
+ ` ${c.dim("\u25C8")} ${c.dim("main")} ${c.dim("\u00B7")} ${c.dim(`${group.main.sessionCount} session${group.main.sessionCount === 1 ? "" : "s"}`)}${prompt}`,
399
+ )
400
+ }
401
+
402
+ // Worktree sessions
403
+ for (const wt of group.worktrees) {
404
+ const name = wt.worktreeName ?? "unknown"
405
+ const prompt = wt.latestPrompt
406
+ ? ` ${c.dim(c.italic(`"${truncate(wt.latestPrompt, 60)}"`))}`
407
+ : ""
408
+ console.log(
409
+ ` ${c.dim("\u25C8")} ${c.dim(name)} ${c.dim("\u00B7")} ${c.dim(`${wt.sessionCount} session${wt.sessionCount === 1 ? "" : "s"}`)}${prompt}`,
410
+ )
411
+ }
412
+ }
413
+ }
414
+
240
415
  // ---------------------------------------------------------------------------
241
416
  // Help screen
242
417
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccy/fell",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Interactive git worktree manager. Navigate, inspect, delete, and prune worktrees with async PR status fetching.",
5
5
  "license": "MIT",
6
6
  "bin": {