ghcrawl 0.3.0 → 0.5.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/dist/tui/app.js CHANGED
@@ -20,7 +20,7 @@ function createScreen(options) {
20
20
  });
21
21
  }
22
22
  const ACTIVITY_LOG_LIMIT = 200;
23
- const FOOTER_LOG_LINES = 4;
23
+ const FOOTER_LOG_LINES = 3;
24
24
  const UPDATE_TASK_ORDER = ['sync', 'embed', 'cluster'];
25
25
  export async function startTui(params) {
26
26
  const selectedRepository = params.owner && params.repo ? { owner: params.owner, repo: params.repo } : null;
@@ -29,11 +29,15 @@ export async function startTui(params) {
29
29
  let focusPane = 'clusters';
30
30
  const initialPreference = selectedRepository
31
31
  ? getTuiRepositoryPreference(params.service.config, currentRepository.owner, currentRepository.repo)
32
- : { sortMode: 'recent', minClusterSize: 10 };
32
+ : { sortMode: 'recent', minClusterSize: 10, wideLayout: 'columns' };
33
33
  let sortMode = initialPreference.sortMode;
34
34
  let minSize = initialPreference.minClusterSize;
35
+ let wideLayout = initialPreference.wideLayout;
36
+ let showClosed = true;
35
37
  let search = '';
36
38
  let snapshot = null;
39
+ let clusterItems = ['Pick a repository with p'];
40
+ let clusterIndexById = new Map();
37
41
  let clusterDetail = null;
38
42
  let threadDetail = null;
39
43
  let selectedClusterId = null;
@@ -52,6 +56,22 @@ export async function startTui(params) {
52
56
  clusterDetailCache.clear();
53
57
  threadDetailCache.clear();
54
58
  };
59
+ const rebuildClusterItems = () => {
60
+ if (!snapshot) {
61
+ clusterItems = ['Pick a repository with p'];
62
+ clusterIndexById = new Map();
63
+ widgets.clusters.setItems(clusterItems);
64
+ return;
65
+ }
66
+ clusterIndexById = new Map();
67
+ clusterItems = snapshot.clusters.map((cluster, index) => {
68
+ clusterIndexById.set(cluster.clusterId, index);
69
+ const updated = formatClusterDateColumn(cluster.latestUpdatedAt);
70
+ const label = `${String(cluster.totalCount).padStart(3, ' ')} C${String(cluster.clusterId).padStart(5, ' ')} ${String(cluster.pullRequestCount).padStart(2, ' ')}P/${String(cluster.issueCount).padStart(2, ' ')}I ${updated} ${cluster.displayTitle}`;
71
+ return cluster.isClosed ? `{gray-fg}${escapeBlessedText(label)}{/gray-fg}` : escapeBlessedText(label);
72
+ });
73
+ widgets.clusters.setItems(clusterItems);
74
+ };
55
75
  const pushActivity = (message) => {
56
76
  activityLines.push(`${formatActivityTimestamp()} ${message}`);
57
77
  if (activityLines.length > ACTIVITY_LOG_LIMIT) {
@@ -67,6 +87,7 @@ export async function startTui(params) {
67
87
  owner: currentRepository.owner,
68
88
  repo: currentRepository.repo,
69
89
  clusterId,
90
+ clusterRunId: snapshot?.clusterRunId ?? undefined,
70
91
  });
71
92
  clusterDetailCache.set(clusterId, detail);
72
93
  return detail;
@@ -88,6 +109,48 @@ export async function startTui(params) {
88
109
  const loadSelectedThreadDetail = (includeNeighbors) => {
89
110
  threadDetail = selectedMemberThreadId !== null ? loadThreadDetail(selectedMemberThreadId, includeNeighbors) : null;
90
111
  };
112
+ const jumpToThread = (threadId, clusterId) => {
113
+ if (clusterId == null) {
114
+ status = 'Selected thread is not assigned to a cluster';
115
+ render();
116
+ return false;
117
+ }
118
+ const selectFromSnapshot = () => {
119
+ const cluster = snapshot?.clusters.find((item) => item.clusterId === clusterId) ?? null;
120
+ if (!cluster) {
121
+ return false;
122
+ }
123
+ selectedClusterId = cluster.clusterId;
124
+ try {
125
+ clusterDetail = loadClusterDetail(cluster.clusterId);
126
+ }
127
+ catch (error) {
128
+ status = `Cluster ${cluster.clusterId} changed; refreshing view`;
129
+ refreshAll(true);
130
+ return false;
131
+ }
132
+ memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
133
+ selectedMemberThreadId = threadId;
134
+ memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
135
+ loadSelectedThreadDetail(false);
136
+ resetDetailScroll();
137
+ status = `Cluster ${cluster.clusterId} / #${threadDetail?.thread.number ?? '?'}`;
138
+ render();
139
+ return true;
140
+ };
141
+ if (selectFromSnapshot()) {
142
+ return true;
143
+ }
144
+ if (minSize !== 0 || search) {
145
+ minSize = 0;
146
+ search = '';
147
+ refreshAll(false);
148
+ return selectFromSnapshot();
149
+ }
150
+ status = `Cluster ${clusterId} is not available in the current view`;
151
+ render();
152
+ return false;
153
+ };
91
154
  const refreshAll = (preserveSelection) => {
92
155
  const previousClusterId = preserveSelection ? selectedClusterId : null;
93
156
  const previousMemberId = preserveSelection ? selectedMemberThreadId : null;
@@ -98,11 +161,30 @@ export async function startTui(params) {
98
161
  minSize,
99
162
  sort: sortMode,
100
163
  search,
164
+ includeClosedClusters: showClosed,
101
165
  });
102
166
  selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), previousClusterId);
167
+ rebuildClusterItems();
103
168
  if (selectedClusterId !== null) {
104
- clusterDetail = loadClusterDetail(selectedClusterId);
105
- memberRows = buildMemberRows(clusterDetail);
169
+ try {
170
+ clusterDetail = loadClusterDetail(selectedClusterId);
171
+ }
172
+ catch {
173
+ snapshot = params.service.getTuiSnapshot({
174
+ owner: currentRepository.owner,
175
+ repo: currentRepository.repo,
176
+ minSize,
177
+ sort: sortMode,
178
+ search,
179
+ includeClosedClusters: showClosed,
180
+ });
181
+ rebuildClusterItems();
182
+ selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), null);
183
+ clusterDetail = selectedClusterId !== null ? loadClusterDetail(selectedClusterId) : null;
184
+ }
185
+ }
186
+ if (selectedClusterId !== null && clusterDetail) {
187
+ memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
106
188
  selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), previousMemberId);
107
189
  memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
108
190
  loadSelectedThreadDetail(false);
@@ -133,7 +215,7 @@ export async function startTui(params) {
133
215
  const render = () => {
134
216
  const width = widgets.screen.width;
135
217
  const height = widgets.screen.height;
136
- const layout = computeTuiLayout(width, height);
218
+ const layout = computeTuiLayout(width, height, wideLayout);
137
219
  applyRect(widgets.header, layout.header);
138
220
  applyRect(widgets.clusters, layout.clusters);
139
221
  applyRect(widgets.members, layout.members);
@@ -149,15 +231,8 @@ export async function startTui(params) {
149
231
  const clusterStatus = snapshot?.stats.latestClusterRunId != null
150
232
  ? `#${snapshot.stats.latestClusterRunId} ${formatRelativeTime(snapshot.stats.latestClusterRunFinishedAt ?? null)}`
151
233
  : 'never';
152
- widgets.header.setContent(`{bold}${repoLabel}{/bold} {cyan-fg}${snapshot?.stats.openPullRequestCount ?? 0} PR{/cyan-fg} {green-fg}${snapshot?.stats.openIssueCount ?? 0} issues{/green-fg} GH:${ghStatus} Emb:${embedStatus} Cl:${clusterStatus} sort:${sortMode} min:${minSize === 0 ? 'all' : `${minSize}+`} filter:${search || 'none'}`);
153
- const clusterItems = snapshot
154
- ? snapshot.clusters.map((cluster) => {
155
- const updated = cluster.latestUpdatedAt ? cluster.latestUpdatedAt.slice(5, 16).replace('T', ' ') : 'unknown';
156
- return `${String(cluster.totalCount).padStart(3, ' ')} ${String(cluster.pullRequestCount).padStart(2, ' ')}P/${String(cluster.issueCount).padStart(2, ' ')}I ${updated} ${cluster.displayTitle}`;
157
- })
158
- : ['Pick a repository with p'];
159
- widgets.clusters.setItems(clusterItems);
160
- const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId)) : 0;
234
+ widgets.header.setContent(`{bold}${repoLabel}{/bold} {cyan-fg}${snapshot?.stats.openPullRequestCount ?? 0} PR{/cyan-fg} {green-fg}${snapshot?.stats.openIssueCount ?? 0} issues{/green-fg} GH:${ghStatus} Emb:${embedStatus} Cl:${clusterStatus} sort:${sortMode} min:${minSize === 0 ? 'all' : `${minSize}+`} layout:${wideLayout === 'columns' ? 'cols' : 'stack'} closed:${showClosed ? 'shown' : 'hidden'} filter:${search || 'none'}`);
235
+ const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, clusterIndexById.get(selectedClusterId) ?? -1) : 0;
161
236
  widgets.clusters.select(clusterIndex);
162
237
  widgets.members.setItems(memberRows.length > 0 ? memberRows.map((row) => row.label) : ['No members']);
163
238
  if (memberIndex >= 0) {
@@ -173,7 +248,8 @@ export async function startTui(params) {
173
248
  while (footerLines.length < FOOTER_LOG_LINES) {
174
249
  footerLines.unshift('');
175
250
  }
176
- footerLines.push(`${status} | jobs:${activeJobs} | Tab focus j/k move-or-scroll PgUp/PgDn scroll p repos g update s sort f min / filter r refresh o open q quit`);
251
+ footerLines.push(`${status} | jobs:${activeJobs} | h/? help g update p repos u author / filter s sort f min l layout x closed`);
252
+ footerLines.push(`Tab focus arrows move-or-scroll PgUp/PgDn page r refresh o open q quit`);
177
253
  widgets.footer.setContent(footerLines.join('\n'));
178
254
  widgets.screen.render();
179
255
  };
@@ -255,31 +331,54 @@ export async function startTui(params) {
255
331
  render();
256
332
  }
257
333
  };
258
- const moveSelection = (delta) => {
334
+ const moveSelection = (delta, options) => {
259
335
  if (!snapshot)
260
336
  return;
337
+ const steps = Math.max(1, options?.steps ?? 1);
338
+ const wrap = options?.wrap ?? true;
261
339
  if (focusPane === 'clusters') {
262
340
  if (snapshot.clusters.length === 0)
263
341
  return;
264
342
  const currentIndex = Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId));
265
- const nextIndex = (currentIndex + delta + snapshot.clusters.length) % snapshot.clusters.length;
343
+ let nextIndex = currentIndex + delta * steps;
344
+ if (wrap) {
345
+ nextIndex = ((nextIndex % snapshot.clusters.length) + snapshot.clusters.length) % snapshot.clusters.length;
346
+ }
347
+ else {
348
+ nextIndex = Math.max(0, Math.min(snapshot.clusters.length - 1, nextIndex));
349
+ }
266
350
  selectedClusterId = snapshot.clusters[nextIndex]?.clusterId ?? null;
267
351
  if (selectedClusterId !== null) {
268
- clusterDetail = loadClusterDetail(selectedClusterId);
269
- memberRows = buildMemberRows(clusterDetail);
352
+ try {
353
+ clusterDetail = loadClusterDetail(selectedClusterId);
354
+ }
355
+ catch {
356
+ status = 'Cluster data changed; refreshing view';
357
+ refreshAll(true);
358
+ return;
359
+ }
360
+ memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
270
361
  selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), null);
271
362
  memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
272
363
  loadSelectedThreadDetail(false);
273
364
  resetDetailScroll();
274
365
  }
275
- status = `Cluster ${nextIndex + 1}/${snapshot.clusters.length}`;
366
+ status = selectedClusterId !== null ? `Cluster ${selectedClusterId} (${nextIndex + 1}/${snapshot.clusters.length})` : `Cluster ${nextIndex + 1}/${snapshot.clusters.length}`;
276
367
  render();
277
368
  return;
278
369
  }
279
370
  if (focusPane === 'members') {
280
371
  if (memberRows.length === 0)
281
372
  return;
282
- memberIndex = moveSelectableIndex(memberRows, memberIndex < 0 ? 0 : memberIndex, delta);
373
+ let nextIndex = memberIndex < 0 ? 0 : memberIndex;
374
+ for (let index = 0; index < steps; index += 1) {
375
+ const candidateIndex = moveSelectableIndex(memberRows, nextIndex, delta);
376
+ if (!wrap && candidateIndex === nextIndex) {
377
+ break;
378
+ }
379
+ nextIndex = candidateIndex;
380
+ }
381
+ memberIndex = nextIndex;
283
382
  selectedMemberThreadId = selectedThreadIdFromRow(memberRows, memberIndex);
284
383
  loadSelectedThreadDetail(false);
285
384
  resetDetailScroll();
@@ -287,6 +386,17 @@ export async function startTui(params) {
287
386
  render();
288
387
  }
289
388
  };
389
+ const getFocusedListPageSize = () => {
390
+ const listHeight = focusPane === 'clusters' ? Number(widgets.clusters.height) : Number(widgets.members.height);
391
+ return Math.max(1, listHeight - 4);
392
+ };
393
+ const pageFocusedPane = (delta) => {
394
+ if (focusPane === 'detail') {
395
+ scrollDetail(delta * 12);
396
+ return;
397
+ }
398
+ moveSelection(delta, { steps: getFocusedListPageSize(), wrap: false });
399
+ };
290
400
  const promptFilter = () => {
291
401
  modalOpen = true;
292
402
  const prompt = blessed.prompt({
@@ -325,6 +435,50 @@ export async function startTui(params) {
325
435
  status = `Opened ${url}`;
326
436
  render();
327
437
  };
438
+ const promptAuthorThreads = () => {
439
+ if (modalOpen)
440
+ return;
441
+ const authorLogin = threadDetail?.thread.authorLogin?.trim() ?? '';
442
+ if (!authorLogin) {
443
+ status = 'Selected thread has no author login';
444
+ render();
445
+ return;
446
+ }
447
+ void (async () => {
448
+ modalOpen = true;
449
+ try {
450
+ const response = params.service.listAuthorThreads({
451
+ owner: currentRepository.owner,
452
+ repo: currentRepository.repo,
453
+ login: authorLogin,
454
+ });
455
+ const choice = await promptAuthorThreadChoice(widgets.screen, response.authorLogin, response.threads);
456
+ if (!choice) {
457
+ render();
458
+ return;
459
+ }
460
+ jumpToThread(choice.threadId, choice.clusterId);
461
+ updateFocus('members');
462
+ }
463
+ finally {
464
+ modalOpen = false;
465
+ }
466
+ })();
467
+ };
468
+ const openHelp = () => {
469
+ if (modalOpen)
470
+ return;
471
+ void (async () => {
472
+ modalOpen = true;
473
+ try {
474
+ await promptHelp(widgets.screen);
475
+ render();
476
+ }
477
+ finally {
478
+ modalOpen = false;
479
+ }
480
+ })();
481
+ };
328
482
  const promptUpdatePipeline = () => {
329
483
  if (modalOpen || hasActiveJobs()) {
330
484
  if (hasActiveJobs()) {
@@ -379,17 +533,49 @@ export async function startTui(params) {
379
533
  repo: currentRepository.repo,
380
534
  minClusterSize: minSize,
381
535
  sortMode,
536
+ wideLayout,
537
+ });
538
+ };
539
+ const withLoadingOverlay = async (message, task) => {
540
+ const box = blessed.box({
541
+ parent: widgets.screen,
542
+ border: 'line',
543
+ label: ' Loading ',
544
+ width: '56%',
545
+ height: 7,
546
+ top: 'center',
547
+ left: 'center',
548
+ tags: true,
549
+ content: `${message}\n\nThis can take a few seconds on large repos.`,
550
+ style: {
551
+ border: { fg: '#5bc0eb' },
552
+ fg: 'white',
553
+ bg: '#101522',
554
+ },
382
555
  });
556
+ widgets.screen.render();
557
+ await new Promise((resolve) => setTimeout(resolve, 0));
558
+ try {
559
+ return await task();
560
+ }
561
+ finally {
562
+ box.destroy();
563
+ widgets.screen.render();
564
+ }
383
565
  };
384
566
  const switchRepository = (target, overrides) => {
385
567
  currentRepository = target;
386
568
  const preference = getTuiRepositoryPreference(params.service.config, target.owner, target.repo);
387
569
  minSize = overrides?.minClusterSize ?? preference.minClusterSize;
388
570
  sortMode = overrides?.sortMode ?? preference.sortMode;
571
+ wideLayout = preference.wideLayout;
389
572
  persistRepositoryPreference();
390
573
  clearCaches();
391
574
  search = '';
392
575
  snapshot = null;
576
+ clusterItems = ['Pick a repository with p'];
577
+ clusterIndexById = new Map();
578
+ widgets.clusters.setItems(clusterItems);
393
579
  clusterDetail = null;
394
580
  threadDetail = null;
395
581
  selectedClusterId = null;
@@ -447,7 +633,9 @@ export async function startTui(params) {
447
633
  return;
448
634
  }
449
635
  if (choice.kind === 'existing') {
450
- switchRepository(choice.target);
636
+ await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
637
+ switchRepository(choice.target);
638
+ });
451
639
  pushActivity(`[repo] switched to ${choice.target.owner}/${choice.target.repo}`);
452
640
  updateFocus('clusters');
453
641
  return;
@@ -476,7 +664,9 @@ export async function startTui(params) {
476
664
  return false;
477
665
  }
478
666
  if (choice.kind === 'existing') {
479
- switchRepository(choice.target);
667
+ await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
668
+ switchRepository(choice.target);
669
+ });
480
670
  pushActivity(`[repo] opened ${choice.target.owner}/${choice.target.repo}`);
481
671
  updateFocus('clusters');
482
672
  return true;
@@ -496,20 +686,25 @@ export async function startTui(params) {
496
686
  modalOpen = false;
497
687
  }
498
688
  };
499
- widgets.screen.key(['q', 'C-c'], () => {
689
+ widgets.screen.key(['q'], () => {
690
+ if (modalOpen)
691
+ return;
692
+ widgets.screen.destroy();
693
+ });
694
+ widgets.screen.key(['C-c'], () => {
500
695
  widgets.screen.destroy();
501
696
  });
502
- widgets.screen.key(['tab'], () => {
697
+ widgets.screen.key(['tab', 'right'], () => {
503
698
  if (modalOpen)
504
699
  return;
505
700
  updateFocus(cycleFocusPane(focusPane, 1));
506
701
  });
507
- widgets.screen.key(['S-tab'], () => {
702
+ widgets.screen.key(['S-tab', 'left'], () => {
508
703
  if (modalOpen)
509
704
  return;
510
705
  updateFocus(cycleFocusPane(focusPane, -1));
511
706
  });
512
- widgets.screen.key(['j', 'down'], () => {
707
+ widgets.screen.key(['down'], () => {
513
708
  if (modalOpen)
514
709
  return;
515
710
  if (focusPane === 'detail') {
@@ -518,7 +713,7 @@ export async function startTui(params) {
518
713
  }
519
714
  moveSelection(1);
520
715
  });
521
- widgets.screen.key(['k', 'up'], () => {
716
+ widgets.screen.key(['up'], () => {
522
717
  if (modalOpen)
523
718
  return;
524
719
  if (focusPane === 'detail') {
@@ -530,12 +725,12 @@ export async function startTui(params) {
530
725
  widgets.screen.key(['pageup'], () => {
531
726
  if (modalOpen)
532
727
  return;
533
- scrollDetail(-12);
728
+ pageFocusedPane(-1);
534
729
  });
535
730
  widgets.screen.key(['pagedown'], () => {
536
731
  if (modalOpen)
537
732
  return;
538
- scrollDetail(12);
733
+ pageFocusedPane(1);
539
734
  });
540
735
  widgets.screen.key(['home'], () => {
541
736
  if (modalOpen)
@@ -582,11 +777,31 @@ export async function startTui(params) {
582
777
  status = `Min size: ${minSize === 0 ? 'all' : `${minSize}+`}`;
583
778
  refreshAll(false);
584
779
  });
780
+ widgets.screen.key(['l'], () => {
781
+ if (modalOpen)
782
+ return;
783
+ wideLayout = wideLayout === 'columns' ? 'right-stack' : 'columns';
784
+ persistRepositoryPreference();
785
+ status = `Layout: ${wideLayout === 'columns' ? 'three columns' : 'wide left + stacked right'}`;
786
+ render();
787
+ });
788
+ widgets.screen.key(['x'], () => {
789
+ if (modalOpen)
790
+ return;
791
+ showClosed = !showClosed;
792
+ status = showClosed ? 'Showing closed clusters and members' : 'Hiding closed clusters and members';
793
+ refreshAll(true);
794
+ });
585
795
  widgets.screen.key(['/'], () => {
586
796
  if (modalOpen)
587
797
  return;
588
798
  promptFilter();
589
799
  });
800
+ widgets.screen.key(['h', '?'], () => {
801
+ if (modalOpen)
802
+ return;
803
+ openHelp();
804
+ });
590
805
  widgets.screen.key(['p'], () => browseRepositories());
591
806
  widgets.screen.key(['g'], () => {
592
807
  if (modalOpen)
@@ -604,6 +819,11 @@ export async function startTui(params) {
604
819
  return;
605
820
  openSelectedThread();
606
821
  });
822
+ widgets.screen.key(['u'], () => {
823
+ if (modalOpen)
824
+ return;
825
+ promptAuthorThreads();
826
+ });
607
827
  widgets.screen.on('resize', () => render());
608
828
  widgets.screen.on('destroy', () => {
609
829
  widgets.screen.program.showCursor();
@@ -642,7 +862,7 @@ function createWidgets(owner, repo) {
642
862
  parent: screen,
643
863
  border: 'line',
644
864
  label: ' Clusters ',
645
- tags: false,
865
+ tags: true,
646
866
  keys: false,
647
867
  style: {
648
868
  border: { fg: '#5bc0eb' },
@@ -655,7 +875,7 @@ function createWidgets(owner, repo) {
655
875
  parent: screen,
656
876
  border: 'line',
657
877
  label: ' Members ',
658
- tags: false,
878
+ tags: true,
659
879
  keys: false,
660
880
  style: {
661
881
  border: { fg: '#9bc53d' },
@@ -695,10 +915,19 @@ export function renderDetailPane(threadDetail, clusterDetail, focusPane) {
695
915
  return 'No cluster selected.\n\nRun `ghcrawl cluster owner/repo` if you have not clustered this repository yet.';
696
916
  }
697
917
  if (!threadDetail) {
698
- return `{bold}${escapeBlessedText(clusterDetail.displayTitle)}{/bold}\n\nSelect a member to inspect thread details.`;
918
+ const representativeLabel = clusterDetail.representativeNumber !== null && clusterDetail.representativeKind !== null
919
+ ? ` (#${clusterDetail.representativeNumber} representative ${clusterDetail.representativeKind === 'pull_request' ? 'pr' : 'issue'})`
920
+ : '';
921
+ return `{bold}Cluster ${clusterDetail.clusterId}${escapeBlessedText(representativeLabel)}{/bold}\n${escapeBlessedText(clusterDetail.displayTitle)}\n\nSelect a member to inspect thread details.`;
699
922
  }
700
923
  const thread = threadDetail.thread;
924
+ const representativeLabel = clusterDetail.representativeNumber !== null && clusterDetail.representativeKind !== null
925
+ ? ` (#${clusterDetail.representativeNumber} representative ${clusterDetail.representativeKind === 'pull_request' ? 'pr' : 'issue'})`
926
+ : '';
701
927
  const labels = thread.labels.length > 0 ? escapeBlessedText(thread.labels.join(', ')) : 'none';
928
+ const closedLabel = thread.isClosed
929
+ ? `{bold}Closed:{/bold} ${escapeBlessedText(thread.closedAtLocal ?? thread.closedAtGh ?? 'yes')} ${thread.closeReasonLocal ? `(${escapeBlessedText(thread.closeReasonLocal)})` : ''}`.trimEnd()
930
+ : '{bold}Closed:{/bold} no';
702
931
  const summaries = Object.entries(threadDetail.summaries)
703
932
  .map(([key, value]) => `{bold}${key}:{/bold}\n${escapeBlessedText(value)}`)
704
933
  .join('\n\n');
@@ -710,9 +939,12 @@ export function renderDetailPane(threadDetail, clusterDetail, focusPane) {
710
939
  ? 'No neighbors available.'
711
940
  : 'Neighbors load when the detail pane is focused.';
712
941
  return [
942
+ `{bold}Cluster ${clusterDetail.clusterId}${escapeBlessedText(representativeLabel)}{/bold}`,
943
+ '',
713
944
  `{bold}${thread.kind} #${thread.number}{/bold} ${escapeBlessedText(thread.title)}`,
714
945
  '',
715
946
  `{bold}Author:{/bold} ${escapeBlessedText(thread.authorLogin ?? 'unknown')}`,
947
+ closedLabel,
716
948
  `{bold}Updated:{/bold} ${thread.updatedAtGh ?? 'unknown'}`,
717
949
  `{bold}Labels:{/bold} ${labels}`,
718
950
  `{bold}URL:{/bold} ${escapeBlessedText(thread.htmlUrl)}`,
@@ -789,6 +1021,123 @@ export function buildUpdatePipelineLabels(stats, selection, now = new Date()) {
789
1021
  return `${mark} ${title} ${describeUpdateTask(task, stats, now)}`;
790
1022
  });
791
1023
  }
1024
+ export function buildHelpContent() {
1025
+ return [
1026
+ '{bold}ghcrawl TUI Help{/bold}',
1027
+ '',
1028
+ '{bold}Navigation{/bold}',
1029
+ 'Tab / Shift-Tab cycle focus across clusters, members, and detail',
1030
+ 'Left / Right cycle focus backward or forward across panes',
1031
+ 'Up / Down move selection, or scroll detail when detail is focused',
1032
+ 'Enter clusters -> members, members -> detail',
1033
+ 'PgUp / PgDn page through the focused pane or this help popup faster',
1034
+ 'Home / End jump to the top or bottom of detail or help',
1035
+ '',
1036
+ '{bold}Views And Filters{/bold}',
1037
+ 's cycle cluster sort mode',
1038
+ 'f cycle minimum cluster size filter',
1039
+ 'l toggle wide layout: columns vs. wide-left stacked-right',
1040
+ 'x show or hide locally closed clusters and members',
1041
+ '/ filter clusters by title/member text',
1042
+ 'r refresh the current local view from SQLite',
1043
+ '',
1044
+ '{bold}Actions{/bold}',
1045
+ 'g open the staged update pipeline (GitHub, embeddings, clusters)',
1046
+ 'p open the repository browser / sync a new repository',
1047
+ 'u show all open threads for the selected author',
1048
+ 'o open the selected thread URL in your browser',
1049
+ '',
1050
+ '{bold}Help And Exit{/bold}',
1051
+ 'h or ? open this help popup',
1052
+ 'q quit the TUI (or close this popup)',
1053
+ 'Esc close this popup',
1054
+ '',
1055
+ '{bold}Notes{/bold}',
1056
+ 'Clusters show C<clusterId> so the cluster id is easy to copy into CLI or skill flows.',
1057
+ 'The footer only shows the short command list. Open help to see the full list.',
1058
+ 'This popup scrolls. Use arrows, PgUp/PgDn, Home, and End if it does not fit.',
1059
+ ].join('\n');
1060
+ }
1061
+ async function promptHelp(screen) {
1062
+ const modalWidth = '86%';
1063
+ const box = blessed.box({
1064
+ parent: screen,
1065
+ border: 'line',
1066
+ label: ' Help ',
1067
+ tags: true,
1068
+ scrollable: true,
1069
+ alwaysScroll: true,
1070
+ keys: true,
1071
+ vi: true,
1072
+ mouse: false,
1073
+ top: 'center',
1074
+ left: 'center',
1075
+ width: modalWidth,
1076
+ height: '80%',
1077
+ padding: {
1078
+ left: 1,
1079
+ right: 1,
1080
+ },
1081
+ scrollbar: {
1082
+ ch: ' ',
1083
+ },
1084
+ style: {
1085
+ border: { fg: '#5bc0eb' },
1086
+ fg: 'white',
1087
+ bg: '#101522',
1088
+ scrollbar: { bg: '#5bc0eb' },
1089
+ },
1090
+ content: buildHelpContent(),
1091
+ });
1092
+ const help = blessed.box({
1093
+ parent: screen,
1094
+ width: modalWidth,
1095
+ height: 1,
1096
+ bottom: 1,
1097
+ left: 'center',
1098
+ tags: false,
1099
+ content: 'Scroll with arrows, PgUp/PgDn, Home, End. Press Esc, q, h, ?, or Enter to close.',
1100
+ style: { fg: 'black', bg: '#5bc0eb' },
1101
+ });
1102
+ box.focus();
1103
+ box.setScroll(0);
1104
+ screen.render();
1105
+ return await new Promise((resolve) => {
1106
+ const finish = () => {
1107
+ screen.off('keypress', handleKeypress);
1108
+ box.destroy();
1109
+ help.destroy();
1110
+ screen.render();
1111
+ resolve();
1112
+ };
1113
+ const handleKeypress = (char, key) => {
1114
+ if (key.name === 'escape' || key.name === 'enter' || key.name === 'q' || key.name === 'h' || char === '?') {
1115
+ finish();
1116
+ return;
1117
+ }
1118
+ if (key.name === 'pageup') {
1119
+ box.scroll(-12);
1120
+ screen.render();
1121
+ return;
1122
+ }
1123
+ if (key.name === 'pagedown') {
1124
+ box.scroll(12);
1125
+ screen.render();
1126
+ return;
1127
+ }
1128
+ if (key.name === 'home') {
1129
+ box.setScroll(0);
1130
+ screen.render();
1131
+ return;
1132
+ }
1133
+ if (key.name === 'end') {
1134
+ box.setScrollPerc(100);
1135
+ screen.render();
1136
+ }
1137
+ };
1138
+ screen.on('keypress', handleKeypress);
1139
+ });
1140
+ }
792
1141
  async function promptUpdatePipelineSelection(screen, stats) {
793
1142
  const selection = { sync: true, embed: true, cluster: true };
794
1143
  const modalWidth = '76%';
@@ -818,7 +1167,7 @@ async function promptUpdatePipelineSelection(screen, stats) {
818
1167
  height: 4,
819
1168
  style: { fg: 'white', bg: '#101522' },
820
1169
  content: 'Usually you want all three. Run order is fixed: GitHub sync/reconcile -> embeddings -> clusters.\n' +
821
- 'Toggle with space, move with j/k or arrows, Enter to start, Esc to cancel.',
1170
+ 'Toggle with space, move with arrows, Enter to start, Esc to cancel.',
822
1171
  });
823
1172
  box.focus();
824
1173
  box.select(0);
@@ -842,7 +1191,7 @@ async function promptUpdatePipelineSelection(screen, stats) {
842
1191
  resolve(value);
843
1192
  };
844
1193
  const handleKeypress = (_char, key) => {
845
- if (key.name === 'escape') {
1194
+ if (key.name === 'escape' || key.name === 'q') {
846
1195
  finish(null);
847
1196
  return;
848
1197
  }
@@ -875,6 +1224,67 @@ export function getRepositoryChoices(service, now = new Date()) {
875
1224
  { kind: 'new', label: '+ Sync a new repository' },
876
1225
  ];
877
1226
  }
1227
+ async function promptAuthorThreadChoice(screen, authorLogin, threads) {
1228
+ const choices = threads.map((item) => {
1229
+ const match = item.strongestSameAuthorMatch;
1230
+ const matchLabel = match ? ` sim:${(match.score * 100).toFixed(1)}% -> #${match.number}` : ' sim:none';
1231
+ const clusterLabel = item.thread.clusterId ? `C${item.thread.clusterId}` : 'C-';
1232
+ return {
1233
+ threadId: item.thread.id,
1234
+ clusterId: item.thread.clusterId,
1235
+ label: `#${item.thread.number} ${item.thread.kind === 'pull_request' ? 'pr' : 'issue'} ${clusterLabel}${matchLabel} ${item.thread.title}`,
1236
+ };
1237
+ });
1238
+ const box = blessed.list({
1239
+ parent: screen,
1240
+ border: 'line',
1241
+ label: ` @${authorLogin} Threads `,
1242
+ keys: true,
1243
+ vi: true,
1244
+ mouse: false,
1245
+ top: 'center',
1246
+ left: 'center',
1247
+ width: '80%',
1248
+ height: '70%',
1249
+ style: {
1250
+ border: { fg: '#fde74c' },
1251
+ item: { fg: 'white' },
1252
+ selected: { bg: '#fde74c', fg: 'black', bold: true },
1253
+ },
1254
+ items: choices.length > 0 ? choices.map((choice) => choice.label) : ['No open threads for this author'],
1255
+ });
1256
+ const help = blessed.box({
1257
+ parent: screen,
1258
+ bottom: 0,
1259
+ left: 0,
1260
+ width: '100%',
1261
+ height: 1,
1262
+ content: 'Enter jumps to the selected thread. Esc cancels.',
1263
+ style: { fg: 'black', bg: '#fde74c' },
1264
+ });
1265
+ box.focus();
1266
+ box.select(0);
1267
+ screen.render();
1268
+ return await new Promise((resolve) => {
1269
+ const teardown = () => {
1270
+ screen.off('keypress', handleKeypress);
1271
+ box.destroy();
1272
+ help.destroy();
1273
+ screen.render();
1274
+ };
1275
+ const finish = (value) => {
1276
+ teardown();
1277
+ resolve(value);
1278
+ };
1279
+ const handleKeypress = (_char, key) => {
1280
+ if (key.name === 'escape' || key.name === 'q') {
1281
+ finish(null);
1282
+ }
1283
+ };
1284
+ screen.on('keypress', handleKeypress);
1285
+ box.on('select', (_item, index) => finish(choices[index] ?? null));
1286
+ });
1287
+ }
878
1288
  async function promptRepositoryChoice(screen, service) {
879
1289
  const choices = getRepositoryChoices(service);
880
1290
  const box = blessed.list({
@@ -919,7 +1329,7 @@ async function promptRepositoryChoice(screen, service) {
919
1329
  resolve(value);
920
1330
  };
921
1331
  const handleKeypress = (_char, key) => {
922
- if (key.name === 'escape') {
1332
+ if (key.name === 'escape' || key.name === 'q') {
923
1333
  finish(null);
924
1334
  return;
925
1335
  }
@@ -989,6 +1399,7 @@ async function runColdStartSetup(service, screen, target, log, footer) {
989
1399
  repo: target.repo,
990
1400
  minClusterSize: 1,
991
1401
  sortMode: 'recent',
1402
+ wideLayout: 'columns',
992
1403
  });
993
1404
  log?.log('[setup] initial setup complete');
994
1405
  return true;
@@ -1014,6 +1425,26 @@ function parseDateOrNull(value) {
1014
1425
  const parsed = Date.parse(value);
1015
1426
  return Number.isNaN(parsed) ? null : parsed;
1016
1427
  }
1428
+ export function formatClusterDateColumn(value, locales) {
1429
+ if (!value)
1430
+ return 'unknown';
1431
+ const parsed = new Date(value);
1432
+ if (Number.isNaN(parsed.getTime()))
1433
+ return value;
1434
+ const month = String(parsed.getMonth() + 1).padStart(2, '0');
1435
+ const day = String(parsed.getDate()).padStart(2, '0');
1436
+ const hour = String(parsed.getHours()).padStart(2, '0');
1437
+ const minute = String(parsed.getMinutes()).padStart(2, '0');
1438
+ const ordering = new Intl.DateTimeFormat(locales, {
1439
+ month: '2-digit',
1440
+ day: '2-digit',
1441
+ })
1442
+ .formatToParts(parsed)
1443
+ .filter((part) => part.type === 'month' || part.type === 'day')
1444
+ .map((part) => part.type);
1445
+ const date = ordering[0] === 'day' ? `${day}-${month}` : `${month}-${day}`;
1446
+ return `${date} ${hour}:${minute}`;
1447
+ }
1017
1448
  function formatAge(diffMs) {
1018
1449
  const safeDiffMs = Math.max(0, diffMs);
1019
1450
  const minuteMs = 60_000;