@doccy/fell 0.1.3 → 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.
- package/cli.ts +267 -125
- package/lib/git.ts +516 -3
- package/lib/tui.ts +176 -1
- 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
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
resultLines.push(`${c.yellow("!")} ${entry.label}: has uncommitted changes`)
|
|
489
|
+
item.deleteStatus = { phase: "needs-force" }
|
|
480
490
|
} else {
|
|
481
|
-
|
|
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
|
-
//
|
|
491
|
-
await
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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.
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -838,7 +953,7 @@ async function handleBrowseKey(state: State, key: Key): Promise<void> {
|
|
|
838
953
|
}
|
|
839
954
|
|
|
840
955
|
// Quit
|
|
841
|
-
if (key === "ctrl-c" || ch === "q") {
|
|
956
|
+
if (key === "ctrl-c" || key === "escape" || ch === "q") {
|
|
842
957
|
state.shouldQuit = true
|
|
843
958
|
return
|
|
844
959
|
}
|
|
@@ -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
|
|
937
|
-
const fileStatuses = await Promise.all(
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
1031
|
-
if (hasLoading ||
|
|
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
|
|
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") {
|
|
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" }
|
|
@@ -1201,6 +1339,10 @@ async function main() {
|
|
|
1201
1339
|
clearInterval(spinnerTimer)
|
|
1202
1340
|
} finally {
|
|
1203
1341
|
cleanup()
|
|
1342
|
+
// Force exit immediately. Background async tasks (PR fetching, size
|
|
1343
|
+
// estimation) hold the event loop open. They're non-critical UI state
|
|
1344
|
+
// -- no data corruption risk from terminating mid-flight.
|
|
1345
|
+
process.exit(0)
|
|
1204
1346
|
}
|
|
1205
1347
|
}
|
|
1206
1348
|
|
package/lib/git.ts
CHANGED
|
@@ -130,6 +130,10 @@ export async function listWorktrees(): Promise<Worktree[]> {
|
|
|
130
130
|
/**
|
|
131
131
|
* Remove a worktree directory and its git administrative tracking.
|
|
132
132
|
* Use force when the worktree has uncommitted changes.
|
|
133
|
+
*
|
|
134
|
+
* Handles zombie worktrees (directory exists but .git file is missing):
|
|
135
|
+
* falls back to `git worktree prune` to clean the reference, then
|
|
136
|
+
* removes the leftover directory manually.
|
|
133
137
|
*/
|
|
134
138
|
export async function removeWorktree(
|
|
135
139
|
path: string,
|
|
@@ -137,10 +141,29 @@ export async function removeWorktree(
|
|
|
137
141
|
): Promise<{ ok: boolean; error?: string }> {
|
|
138
142
|
const flags = force ? ["--force"] : []
|
|
139
143
|
const result = await $`git worktree remove ${flags} ${path}`.nothrow().quiet()
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
|
|
145
|
+
if (result.exitCode === 0) return { ok: true }
|
|
146
|
+
|
|
147
|
+
const stderr = result.stderr.toString().trim()
|
|
148
|
+
|
|
149
|
+
// Zombie worktree: directory exists but .git is missing.
|
|
150
|
+
// git worktree remove refuses to act, so we prune the reference
|
|
151
|
+
// and remove the leftover directory ourselves.
|
|
152
|
+
const isZombie =
|
|
153
|
+
stderr.includes("does not exist") &&
|
|
154
|
+
(stderr.includes(".git") || stderr.includes("validation failed"))
|
|
155
|
+
|
|
156
|
+
if (isZombie) {
|
|
157
|
+
// Prune cleans the stale git reference
|
|
158
|
+
await $`git worktree prune`.nothrow().quiet()
|
|
159
|
+
|
|
160
|
+
// Remove the leftover directory if it still exists
|
|
161
|
+
await $`rm -rf ${path}`.nothrow().quiet()
|
|
162
|
+
|
|
163
|
+
return { ok: true }
|
|
142
164
|
}
|
|
143
|
-
|
|
165
|
+
|
|
166
|
+
return { ok: false, error: stderr }
|
|
144
167
|
}
|
|
145
168
|
|
|
146
169
|
/**
|
|
@@ -412,3 +435,493 @@ export async function fetchPrForBranch(
|
|
|
412
435
|
return null
|
|
413
436
|
}
|
|
414
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
|
// ---------------------------------------------------------------------------
|