ghcrawl 0.4.0 → 0.6.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
@@ -1,4 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
2
6
  import blessed from 'neo-blessed';
3
7
  import { getTuiRepositoryPreference, writeTuiRepositoryPreference } from '@ghcrawl/api-core';
4
8
  import { buildMemberRows, cycleFocusPane, cycleMinSizeFilter, cycleSortMode, findSelectableIndex, moveSelectableIndex, preserveSelectedId, selectedThreadIdFromRow, } from './state.js';
@@ -22,6 +26,30 @@ function createScreen(options) {
22
26
  const ACTIVITY_LOG_LIMIT = 200;
23
27
  const FOOTER_LOG_LINES = 3;
24
28
  const UPDATE_TASK_ORDER = ['sync', 'embed', 'cluster'];
29
+ export function buildRefreshCliArgs(target, selection) {
30
+ const args = ['refresh', `${target.owner}/${target.repo}`];
31
+ if (!selection.sync)
32
+ args.push('--no-sync');
33
+ if (!selection.embed)
34
+ args.push('--no-embed');
35
+ if (!selection.cluster)
36
+ args.push('--no-cluster');
37
+ return args;
38
+ }
39
+ function createCliLaunch(args) {
40
+ const here = path.dirname(fileURLToPath(import.meta.url));
41
+ const distEntrypoint = path.resolve(here, '..', 'main.js');
42
+ if (existsSync(distEntrypoint)) {
43
+ return { command: process.execPath, args: [distEntrypoint, ...args] };
44
+ }
45
+ const sourceEntrypoint = path.resolve(here, '..', 'main.ts');
46
+ const require = createRequire(import.meta.url);
47
+ const tsxLoader = require.resolve('tsx');
48
+ return {
49
+ command: process.execPath,
50
+ args: ['--conditions=development', '--import', tsxLoader, sourceEntrypoint, ...args],
51
+ };
52
+ }
25
53
  export async function startTui(params) {
26
54
  const selectedRepository = params.owner && params.repo ? { owner: params.owner, repo: params.repo } : null;
27
55
  let currentRepository = selectedRepository ?? { owner: '', repo: '' };
@@ -36,6 +64,8 @@ export async function startTui(params) {
36
64
  let showClosed = true;
37
65
  let search = '';
38
66
  let snapshot = null;
67
+ let clusterItems = ['Pick a repository with p'];
68
+ let clusterIndexById = new Map();
39
69
  let clusterDetail = null;
40
70
  let threadDetail = null;
41
71
  let selectedClusterId = null;
@@ -49,18 +79,41 @@ export async function startTui(params) {
49
79
  let syncJobRunning = false;
50
80
  let embedJobRunning = false;
51
81
  let clusterJobRunning = false;
82
+ let activeJob = null;
52
83
  let modalOpen = false;
84
+ let exitRequested = false;
53
85
  const clearCaches = () => {
54
86
  clusterDetailCache.clear();
55
87
  threadDetailCache.clear();
56
88
  };
57
- const pushActivity = (message) => {
58
- activityLines.push(`${formatActivityTimestamp()} ${message}`);
89
+ const rebuildClusterItems = () => {
90
+ if (!snapshot) {
91
+ clusterItems = ['Pick a repository with p'];
92
+ clusterIndexById = new Map();
93
+ widgets.clusters.setItems(clusterItems);
94
+ return;
95
+ }
96
+ clusterIndexById = new Map();
97
+ clusterItems = snapshot.clusters.map((cluster, index) => {
98
+ clusterIndexById.set(cluster.clusterId, index);
99
+ const updated = formatClusterDateColumn(cluster.latestUpdatedAt);
100
+ 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}`;
101
+ return cluster.isClosed ? `{gray-fg}${escapeBlessedText(label)}{/gray-fg}` : escapeBlessedText(label);
102
+ });
103
+ widgets.clusters.setItems(clusterItems);
104
+ };
105
+ const pushActivity = (message, options) => {
106
+ activityLines.push(options?.raw === true ? message : `${formatActivityTimestamp()} ${message}`);
59
107
  if (activityLines.length > ACTIVITY_LOG_LIMIT) {
60
108
  activityLines.splice(0, activityLines.length - ACTIVITY_LOG_LIMIT);
61
109
  }
62
110
  render();
63
111
  };
112
+ const setActiveJobFlags = (selection) => {
113
+ syncJobRunning = selection?.sync === true;
114
+ embedJobRunning = selection?.embed === true;
115
+ clusterJobRunning = selection?.cluster === true;
116
+ };
64
117
  const loadClusterDetail = (clusterId) => {
65
118
  const cached = clusterDetailCache.get(clusterId);
66
119
  if (cached)
@@ -69,6 +122,7 @@ export async function startTui(params) {
69
122
  owner: currentRepository.owner,
70
123
  repo: currentRepository.repo,
71
124
  clusterId,
125
+ clusterRunId: snapshot?.clusterRunId ?? undefined,
72
126
  });
73
127
  clusterDetailCache.set(clusterId, detail);
74
128
  return detail;
@@ -102,7 +156,14 @@ export async function startTui(params) {
102
156
  return false;
103
157
  }
104
158
  selectedClusterId = cluster.clusterId;
105
- clusterDetail = loadClusterDetail(cluster.clusterId);
159
+ try {
160
+ clusterDetail = loadClusterDetail(cluster.clusterId);
161
+ }
162
+ catch (error) {
163
+ status = `Cluster ${cluster.clusterId} changed; refreshing view`;
164
+ refreshAll(true);
165
+ return false;
166
+ }
106
167
  memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
107
168
  selectedMemberThreadId = threadId;
108
169
  memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
@@ -138,8 +199,26 @@ export async function startTui(params) {
138
199
  includeClosedClusters: showClosed,
139
200
  });
140
201
  selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), previousClusterId);
202
+ rebuildClusterItems();
141
203
  if (selectedClusterId !== null) {
142
- clusterDetail = loadClusterDetail(selectedClusterId);
204
+ try {
205
+ clusterDetail = loadClusterDetail(selectedClusterId);
206
+ }
207
+ catch {
208
+ snapshot = params.service.getTuiSnapshot({
209
+ owner: currentRepository.owner,
210
+ repo: currentRepository.repo,
211
+ minSize,
212
+ sort: sortMode,
213
+ search,
214
+ includeClosedClusters: showClosed,
215
+ });
216
+ rebuildClusterItems();
217
+ selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), null);
218
+ clusterDetail = selectedClusterId !== null ? loadClusterDetail(selectedClusterId) : null;
219
+ }
220
+ }
221
+ if (selectedClusterId !== null && clusterDetail) {
143
222
  memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
144
223
  selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), previousMemberId);
145
224
  memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
@@ -188,15 +267,7 @@ export async function startTui(params) {
188
267
  ? `#${snapshot.stats.latestClusterRunId} ${formatRelativeTime(snapshot.stats.latestClusterRunFinishedAt ?? null)}`
189
268
  : 'never';
190
269
  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'}`);
191
- const clusterItems = snapshot
192
- ? snapshot.clusters.map((cluster) => {
193
- const updated = formatClusterDateColumn(cluster.latestUpdatedAt);
194
- 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}`;
195
- return cluster.isClosed ? `{gray-fg}${escapeBlessedText(label)}{/gray-fg}` : escapeBlessedText(label);
196
- })
197
- : ['Pick a repository with p'];
198
- widgets.clusters.setItems(clusterItems);
199
- const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId)) : 0;
270
+ const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, clusterIndexById.get(selectedClusterId) ?? -1) : 0;
200
271
  widgets.clusters.select(clusterIndex);
201
272
  widgets.members.setItems(memberRows.length > 0 ? memberRows.map((row) => row.label) : ['No members']);
202
273
  if (memberIndex >= 0) {
@@ -212,8 +283,8 @@ export async function startTui(params) {
212
283
  while (footerLines.length < FOOTER_LOG_LINES) {
213
284
  footerLines.unshift('');
214
285
  }
215
- footerLines.push(`${status} | jobs:${activeJobs} | h/? help g update p repos u author / filter s sort f min l layout x closed`);
216
- footerLines.push(`Tab focus j/k move-or-scroll PgUp/PgDn scroll r refresh o open q quit`);
286
+ footerLines.push(`${status} | jobs:${activeJobs} | h/? help # jump g update p repos u author / filter s sort f min l layout x closed`);
287
+ footerLines.push(`Tab focus arrows move-or-scroll PgUp/PgDn page r refresh o open q quit`);
217
288
  widgets.footer.setContent(footerLines.join('\n'));
218
289
  widgets.screen.render();
219
290
  };
@@ -226,86 +297,148 @@ export async function startTui(params) {
226
297
  widgets.detail.scroll(offset);
227
298
  widgets.screen.render();
228
299
  };
229
- const runSyncStep = async () => {
230
- if (syncJobRunning) {
231
- throw new Error('GitHub reconciliation already running');
232
- }
233
- syncJobRunning = true;
234
- status = 'Running GitHub reconciliation';
235
- pushActivity('[jobs] starting GitHub reconciliation');
236
- render();
237
- try {
238
- const result = await params.service.syncRepository({
239
- owner: currentRepository.owner,
240
- repo: currentRepository.repo,
241
- onProgress: pushActivity,
242
- });
243
- pushActivity(`[jobs] GitHub reconciliation complete threads=${result.threadsSynced} comments=${result.commentsSynced} closed=${result.threadsClosed}`);
244
- refreshAll(true);
245
- }
246
- finally {
247
- syncJobRunning = false;
248
- status = 'Ready';
249
- render();
250
- }
300
+ const consumeStreamLines = (stream, onLine) => {
301
+ let buffer = '';
302
+ stream.setEncoding('utf8');
303
+ stream.on('data', (chunk) => {
304
+ buffer += chunk;
305
+ while (true) {
306
+ const newlineIndex = buffer.indexOf('\n');
307
+ if (newlineIndex === -1)
308
+ break;
309
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, '').trimEnd();
310
+ buffer = buffer.slice(newlineIndex + 1);
311
+ if (line.length > 0)
312
+ onLine(line);
313
+ }
314
+ });
315
+ stream.on('end', () => {
316
+ const line = buffer.replace(/\r$/, '').trim();
317
+ if (line.length > 0)
318
+ onLine(line);
319
+ });
251
320
  };
252
- const runEmbedStep = async () => {
253
- if (embedJobRunning) {
254
- throw new Error('embed refresh already running');
255
- }
256
- embedJobRunning = true;
257
- status = 'Running embed refresh';
258
- pushActivity('[jobs] starting embed refresh');
259
- render();
260
- try {
261
- const result = await params.service.embedRepository({
262
- owner: currentRepository.owner,
263
- repo: currentRepository.repo,
264
- onProgress: pushActivity,
265
- });
266
- pushActivity(`[jobs] embed refresh complete embeddings=${result.embedded}`);
267
- refreshAll(true);
268
- }
269
- finally {
270
- embedJobRunning = false;
321
+ const finalizeBackgroundJob = (job) => {
322
+ void (async () => {
323
+ const result = await job.exitPromise;
324
+ if (activeJob === job) {
325
+ activeJob = null;
326
+ }
327
+ setActiveJobFlags(null);
328
+ if (job.terminatedByUser) {
329
+ pushActivity(`[jobs] update pipeline terminated for ${job.repo.owner}/${job.repo.repo}`);
330
+ }
331
+ else if (result.error) {
332
+ pushActivity(`[jobs] update pipeline failed for ${job.repo.owner}/${job.repo.repo}: ${result.error.message}`);
333
+ }
334
+ else if (result.code === 0) {
335
+ pushActivity(`[jobs] update pipeline complete for ${job.repo.owner}/${job.repo.repo}`);
336
+ try {
337
+ const parsed = JSON.parse(result.stdout.trim());
338
+ const summaryParts = [
339
+ parsed.sync ? `sync:${parsed.sync.threadsSynced ?? 0} threads` : null,
340
+ parsed.sync ? `closed:${parsed.sync.threadsClosed ?? 0}` : null,
341
+ parsed.embed ? `embed:${parsed.embed.embedded ?? 0}` : null,
342
+ parsed.cluster ? `cluster:${parsed.cluster.clusters ?? 0}` : null,
343
+ parsed.cluster ? `edges:${parsed.cluster.edges ?? 0}` : null,
344
+ ].filter((value) => value !== null);
345
+ if (summaryParts.length > 0) {
346
+ pushActivity(`[jobs] result ${summaryParts.join(' ')}`);
347
+ }
348
+ }
349
+ catch {
350
+ // Ignore malformed stdout; progress is already visible in the activity log.
351
+ }
352
+ if (currentRepository.owner === job.repo.owner && currentRepository.repo === job.repo.repo) {
353
+ refreshAll(true);
354
+ }
355
+ }
356
+ else {
357
+ const exitSuffix = result.signal !== null ? `signal=${result.signal}` : `code=${result.code ?? 1}`;
358
+ pushActivity(`[jobs] update pipeline failed for ${job.repo.owner}/${job.repo.repo}: exited ${exitSuffix}`);
359
+ }
271
360
  status = 'Ready';
272
- render();
273
- }
361
+ if (!exitRequested) {
362
+ render();
363
+ }
364
+ })();
274
365
  };
275
- const runClusterStep = async () => {
276
- if (clusterJobRunning) {
277
- throw new Error('cluster refresh already running');
278
- }
279
- clusterJobRunning = true;
280
- status = 'Running cluster refresh';
281
- pushActivity('[jobs] starting cluster refresh');
282
- render();
283
- try {
284
- const result = params.service.clusterRepository({
285
- owner: currentRepository.owner,
286
- repo: currentRepository.repo,
287
- onProgress: pushActivity,
288
- });
289
- pushActivity(`[jobs] cluster refresh complete clusters=${result.clusters} edges=${result.edges}`);
290
- refreshAll(true);
366
+ const startBackgroundUpdatePipeline = (target, selection) => {
367
+ if (activeJob !== null) {
368
+ pushActivity('[jobs] another update pipeline is already running');
369
+ return false;
291
370
  }
292
- finally {
293
- clusterJobRunning = false;
294
- status = 'Ready';
295
- render();
371
+ if (!selection.sync && !selection.embed && !selection.cluster) {
372
+ pushActivity('[jobs] select at least one update step');
373
+ return false;
296
374
  }
375
+ const cliArgs = buildRefreshCliArgs(target, selection);
376
+ const launch = createCliLaunch(cliArgs);
377
+ const child = spawn(launch.command, launch.args, {
378
+ env: process.env,
379
+ stdio: ['ignore', 'pipe', 'pipe'],
380
+ });
381
+ const job = {
382
+ child,
383
+ repo: target,
384
+ selection,
385
+ stdoutBuffer: '',
386
+ terminatedByUser: false,
387
+ exitPromise: new Promise((resolve) => {
388
+ let resolved = false;
389
+ const finish = (result) => {
390
+ if (resolved)
391
+ return;
392
+ resolved = true;
393
+ resolve(result);
394
+ };
395
+ child.on('error', (error) => {
396
+ finish({ code: null, signal: null, stdout: job.stdoutBuffer, error });
397
+ });
398
+ child.on('close', (code, signal) => {
399
+ finish({ code, signal, stdout: job.stdoutBuffer, error: null });
400
+ });
401
+ }),
402
+ };
403
+ child.stdout.setEncoding('utf8');
404
+ child.stdout.on('data', (chunk) => {
405
+ job.stdoutBuffer += chunk;
406
+ });
407
+ consumeStreamLines(child.stderr, (line) => pushActivity(line, { raw: true }));
408
+ activeJob = job;
409
+ setActiveJobFlags(selection);
410
+ status = `Running update pipeline for ${target.owner}/${target.repo}`;
411
+ pushActivity(`[jobs] starting update pipeline for ${target.owner}/${target.repo}: ${UPDATE_TASK_ORDER.filter((task) => selection[task]).join(' -> ')}`);
412
+ render();
413
+ finalizeBackgroundJob(job);
414
+ return true;
297
415
  };
298
- const moveSelection = (delta) => {
416
+ const moveSelection = (delta, options) => {
299
417
  if (!snapshot)
300
418
  return;
419
+ const steps = Math.max(1, options?.steps ?? 1);
420
+ const wrap = options?.wrap ?? true;
301
421
  if (focusPane === 'clusters') {
302
422
  if (snapshot.clusters.length === 0)
303
423
  return;
304
- const currentIndex = Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId));
305
- const nextIndex = (currentIndex + delta + snapshot.clusters.length) % snapshot.clusters.length;
424
+ const currentIndex = Math.max(0, selectedClusterId === null ? -1 : (clusterIndexById.get(selectedClusterId) ?? -1));
425
+ let nextIndex = currentIndex + delta * steps;
426
+ if (wrap) {
427
+ nextIndex = ((nextIndex % snapshot.clusters.length) + snapshot.clusters.length) % snapshot.clusters.length;
428
+ }
429
+ else {
430
+ nextIndex = Math.max(0, Math.min(snapshot.clusters.length - 1, nextIndex));
431
+ }
306
432
  selectedClusterId = snapshot.clusters[nextIndex]?.clusterId ?? null;
307
433
  if (selectedClusterId !== null) {
308
- clusterDetail = loadClusterDetail(selectedClusterId);
434
+ try {
435
+ clusterDetail = loadClusterDetail(selectedClusterId);
436
+ }
437
+ catch {
438
+ status = 'Cluster data changed; refreshing view';
439
+ refreshAll(true);
440
+ return;
441
+ }
309
442
  memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
310
443
  selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), null);
311
444
  memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
@@ -319,7 +452,15 @@ export async function startTui(params) {
319
452
  if (focusPane === 'members') {
320
453
  if (memberRows.length === 0)
321
454
  return;
322
- memberIndex = moveSelectableIndex(memberRows, memberIndex < 0 ? 0 : memberIndex, delta);
455
+ let nextIndex = memberIndex < 0 ? 0 : memberIndex;
456
+ for (let index = 0; index < steps; index += 1) {
457
+ const candidateIndex = moveSelectableIndex(memberRows, nextIndex, delta);
458
+ if (!wrap && candidateIndex === nextIndex) {
459
+ break;
460
+ }
461
+ nextIndex = candidateIndex;
462
+ }
463
+ memberIndex = nextIndex;
323
464
  selectedMemberThreadId = selectedThreadIdFromRow(memberRows, memberIndex);
324
465
  loadSelectedThreadDetail(false);
325
466
  resetDetailScroll();
@@ -327,6 +468,17 @@ export async function startTui(params) {
327
468
  render();
328
469
  }
329
470
  };
471
+ const getFocusedListPageSize = () => {
472
+ const listHeight = focusPane === 'clusters' ? Number(widgets.clusters.height) : Number(widgets.members.height);
473
+ return Math.max(1, listHeight - 4);
474
+ };
475
+ const pageFocusedPane = (delta) => {
476
+ if (focusPane === 'detail') {
477
+ scrollDetail(delta * 12);
478
+ return;
479
+ }
480
+ moveSelection(delta, { steps: getFocusedListPageSize(), wrap: false });
481
+ };
330
482
  const promptFilter = () => {
331
483
  modalOpen = true;
332
484
  const prompt = blessed.prompt({
@@ -354,6 +506,56 @@ export async function startTui(params) {
354
506
  updateFocus('clusters');
355
507
  });
356
508
  };
509
+ const promptThreadJump = () => {
510
+ if (modalOpen)
511
+ return;
512
+ modalOpen = true;
513
+ const prompt = blessed.prompt({
514
+ parent: widgets.screen,
515
+ border: 'line',
516
+ height: 7,
517
+ width: '60%',
518
+ top: 'center',
519
+ left: 'center',
520
+ label: ' Jump To Issue/PR ',
521
+ tags: true,
522
+ keys: true,
523
+ vi: true,
524
+ style: {
525
+ border: { fg: '#fde74c' },
526
+ bg: '#101522',
527
+ },
528
+ });
529
+ prompt.input('Issue or PR number', '', (_error, value) => {
530
+ prompt.destroy();
531
+ modalOpen = false;
532
+ const parsed = Number((value ?? '').trim());
533
+ if (!Number.isInteger(parsed) || parsed <= 0) {
534
+ status = 'Enter a positive issue or PR number';
535
+ render();
536
+ return;
537
+ }
538
+ try {
539
+ const detail = params.service.getTuiThreadDetail({
540
+ owner: currentRepository.owner,
541
+ repo: currentRepository.repo,
542
+ threadNumber: parsed,
543
+ includeNeighbors: false,
544
+ });
545
+ const jumped = jumpToThread(detail.thread.id, detail.thread.clusterId ?? null);
546
+ if (jumped) {
547
+ status = `Jumped to #${detail.thread.number} in cluster ${detail.thread.clusterId ?? '?'}`;
548
+ updateFocus('members');
549
+ return;
550
+ }
551
+ render();
552
+ }
553
+ catch (error) {
554
+ status = error instanceof Error ? error.message : `Thread #${parsed} was not found`;
555
+ render();
556
+ }
557
+ });
558
+ };
357
559
  const openSelectedThread = () => {
358
560
  const url = threadDetail?.thread.htmlUrl;
359
561
  if (!url) {
@@ -409,6 +611,76 @@ export async function startTui(params) {
409
611
  }
410
612
  })();
411
613
  };
614
+ const promptConfirm = async (label, message) => {
615
+ const box = blessed.box({
616
+ parent: widgets.screen,
617
+ border: 'line',
618
+ label: ` ${label} `,
619
+ tags: true,
620
+ top: 'center',
621
+ left: 'center',
622
+ width: '68%',
623
+ height: 9,
624
+ padding: {
625
+ left: 1,
626
+ right: 1,
627
+ },
628
+ style: {
629
+ border: { fg: '#fde74c' },
630
+ fg: 'white',
631
+ bg: '#101522',
632
+ },
633
+ content: `${message}\n\nPress y or Enter to confirm. Press n or Esc to cancel.`,
634
+ });
635
+ widgets.screen.render();
636
+ return await new Promise((resolve) => {
637
+ const finish = (value) => {
638
+ widgets.screen.off('keypress', handleKeypress);
639
+ box.destroy();
640
+ widgets.screen.render();
641
+ resolve(value);
642
+ };
643
+ const handleKeypress = (char, key) => {
644
+ if (key.name === 'enter' || char.toLowerCase() === 'y') {
645
+ finish(true);
646
+ return;
647
+ }
648
+ if (key.name === 'escape' || char.toLowerCase() === 'n' || key.name === 'q') {
649
+ finish(false);
650
+ }
651
+ };
652
+ widgets.screen.on('keypress', handleKeypress);
653
+ });
654
+ };
655
+ const requestQuit = () => {
656
+ if (modalOpen)
657
+ return;
658
+ void (async () => {
659
+ if (activeJob === null) {
660
+ widgets.screen.destroy();
661
+ return;
662
+ }
663
+ modalOpen = true;
664
+ try {
665
+ const confirmed = await promptConfirm('Stop Update Pipeline', `A background update pipeline is still running for ${activeJob.repo.owner}/${activeJob.repo.repo}.\nQuitting now will send SIGTERM to that refresh process and wait for it to exit.`);
666
+ if (!confirmed) {
667
+ render();
668
+ return;
669
+ }
670
+ exitRequested = true;
671
+ status = 'Stopping background update pipeline';
672
+ pushActivity(`[jobs] stopping update pipeline for ${activeJob.repo.owner}/${activeJob.repo.repo}`);
673
+ render();
674
+ activeJob.terminatedByUser = true;
675
+ activeJob.child.kill('SIGTERM');
676
+ await activeJob.exitPromise;
677
+ widgets.screen.destroy();
678
+ }
679
+ finally {
680
+ modalOpen = false;
681
+ }
682
+ })();
683
+ };
412
684
  const promptUpdatePipeline = () => {
413
685
  if (modalOpen || hasActiveJobs()) {
414
686
  if (hasActiveJobs()) {
@@ -426,7 +698,7 @@ export async function startTui(params) {
426
698
  }
427
699
  const selectedTasks = UPDATE_TASK_ORDER.filter((task) => selection[task]).join(' -> ');
428
700
  pushActivity(`[jobs] queued update pipeline: ${selectedTasks}`);
429
- await runUpdatePipeline(selection);
701
+ startBackgroundUpdatePipeline(currentRepository, selection);
430
702
  updateFocus('clusters');
431
703
  }
432
704
  finally {
@@ -434,29 +706,7 @@ export async function startTui(params) {
434
706
  }
435
707
  })();
436
708
  };
437
- const hasActiveJobs = () => syncJobRunning || embedJobRunning || clusterJobRunning;
438
- const runUpdatePipeline = async (selection) => {
439
- if (hasActiveJobs()) {
440
- pushActivity('[jobs] another update pipeline is already running');
441
- return false;
442
- }
443
- try {
444
- if (selection.sync) {
445
- await runSyncStep();
446
- }
447
- if (selection.embed) {
448
- await runEmbedStep();
449
- }
450
- if (selection.cluster) {
451
- await runClusterStep();
452
- }
453
- return true;
454
- }
455
- catch (error) {
456
- pushActivity(`[jobs] update pipeline failed: ${error instanceof Error ? error.message : String(error)}`);
457
- return false;
458
- }
459
- };
709
+ const hasActiveJobs = () => activeJob !== null;
460
710
  const persistRepositoryPreference = () => {
461
711
  writeTuiRepositoryPreference(params.service.config, {
462
712
  owner: currentRepository.owner,
@@ -466,6 +716,33 @@ export async function startTui(params) {
466
716
  wideLayout,
467
717
  });
468
718
  };
719
+ const withLoadingOverlay = async (message, task) => {
720
+ const box = blessed.box({
721
+ parent: widgets.screen,
722
+ border: 'line',
723
+ label: ' Loading ',
724
+ width: '56%',
725
+ height: 7,
726
+ top: 'center',
727
+ left: 'center',
728
+ tags: true,
729
+ content: `${message}\n\nThis can take a few seconds on large repos.`,
730
+ style: {
731
+ border: { fg: '#5bc0eb' },
732
+ fg: 'white',
733
+ bg: '#101522',
734
+ },
735
+ });
736
+ widgets.screen.render();
737
+ await new Promise((resolve) => setTimeout(resolve, 0));
738
+ try {
739
+ return await task();
740
+ }
741
+ finally {
742
+ box.destroy();
743
+ widgets.screen.render();
744
+ }
745
+ };
469
746
  const switchRepository = (target, overrides) => {
470
747
  currentRepository = target;
471
748
  const preference = getTuiRepositoryPreference(params.service.config, target.owner, target.repo);
@@ -476,6 +753,9 @@ export async function startTui(params) {
476
753
  clearCaches();
477
754
  search = '';
478
755
  snapshot = null;
756
+ clusterItems = ['Pick a repository with p'];
757
+ clusterIndexById = new Map();
758
+ widgets.clusters.setItems(clusterItems);
479
759
  clusterDetail = null;
480
760
  threadDetail = null;
481
761
  selectedClusterId = null;
@@ -485,37 +765,14 @@ export async function startTui(params) {
485
765
  status = `Switched to ${target.owner}/${target.repo}`;
486
766
  refreshAll(false);
487
767
  };
488
- const runRepositoryBootstrap = async (target) => {
768
+ const runRepositoryBootstrap = (target) => {
489
769
  if (hasActiveJobs()) {
490
770
  pushActivity('[repo] repository setup is blocked while jobs are already running');
491
771
  return false;
492
772
  }
493
- status = `Bootstrapping ${target.owner}/${target.repo}`;
494
- render();
495
- try {
496
- pushActivity(`[repo] starting initial update pipeline for ${target.owner}/${target.repo}`);
497
- const previousRepository = currentRepository;
498
- let ok = false;
499
- try {
500
- currentRepository = target;
501
- ok = await runUpdatePipeline({ sync: true, embed: true, cluster: true });
502
- }
503
- finally {
504
- currentRepository = previousRepository;
505
- }
506
- if (!ok) {
507
- return false;
508
- }
509
- pushActivity(`[repo] initial setup complete for ${target.owner}/${target.repo}`);
510
- switchRepository(target, { minClusterSize: 1 });
511
- return true;
512
- }
513
- catch (error) {
514
- pushActivity(`[repo] initial setup failed for ${target.owner}/${target.repo}: ${error instanceof Error ? error.message : String(error)}`);
515
- status = 'Ready';
516
- render();
517
- return false;
518
- }
773
+ switchRepository(target, { minClusterSize: 1 });
774
+ pushActivity(`[repo] opened ${target.owner}/${target.repo}; starting initial update pipeline in the background`);
775
+ return startBackgroundUpdatePipeline(target, { sync: true, embed: true, cluster: true });
519
776
  };
520
777
  const browseRepositories = () => {
521
778
  if (modalOpen)
@@ -533,7 +790,9 @@ export async function startTui(params) {
533
790
  return;
534
791
  }
535
792
  if (choice.kind === 'existing') {
536
- switchRepository(choice.target);
793
+ await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
794
+ switchRepository(choice.target);
795
+ });
537
796
  pushActivity(`[repo] switched to ${choice.target.owner}/${choice.target.repo}`);
538
797
  updateFocus('clusters');
539
798
  return;
@@ -543,7 +802,7 @@ export async function startTui(params) {
543
802
  render();
544
803
  return;
545
804
  }
546
- await runRepositoryBootstrap(target);
805
+ runRepositoryBootstrap(target);
547
806
  updateFocus('clusters');
548
807
  }
549
808
  finally {
@@ -562,7 +821,9 @@ export async function startTui(params) {
562
821
  return false;
563
822
  }
564
823
  if (choice.kind === 'existing') {
565
- switchRepository(choice.target);
824
+ await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
825
+ switchRepository(choice.target);
826
+ });
566
827
  pushActivity(`[repo] opened ${choice.target.owner}/${choice.target.repo}`);
567
828
  updateFocus('clusters');
568
829
  return true;
@@ -571,7 +832,7 @@ export async function startTui(params) {
571
832
  if (!target) {
572
833
  return false;
573
834
  }
574
- const ready = await runRepositoryBootstrap(target);
835
+ const ready = runRepositoryBootstrap(target);
575
836
  if (!ready) {
576
837
  return false;
577
838
  }
@@ -583,24 +844,22 @@ export async function startTui(params) {
583
844
  }
584
845
  };
585
846
  widgets.screen.key(['q'], () => {
586
- if (modalOpen)
587
- return;
588
- widgets.screen.destroy();
847
+ requestQuit();
589
848
  });
590
849
  widgets.screen.key(['C-c'], () => {
591
- widgets.screen.destroy();
850
+ requestQuit();
592
851
  });
593
- widgets.screen.key(['tab'], () => {
852
+ widgets.screen.key(['tab', 'right'], () => {
594
853
  if (modalOpen)
595
854
  return;
596
855
  updateFocus(cycleFocusPane(focusPane, 1));
597
856
  });
598
- widgets.screen.key(['S-tab'], () => {
857
+ widgets.screen.key(['S-tab', 'left'], () => {
599
858
  if (modalOpen)
600
859
  return;
601
860
  updateFocus(cycleFocusPane(focusPane, -1));
602
861
  });
603
- widgets.screen.key(['j', 'down'], () => {
862
+ widgets.screen.key(['down'], () => {
604
863
  if (modalOpen)
605
864
  return;
606
865
  if (focusPane === 'detail') {
@@ -609,7 +868,7 @@ export async function startTui(params) {
609
868
  }
610
869
  moveSelection(1);
611
870
  });
612
- widgets.screen.key(['k', 'up'], () => {
871
+ widgets.screen.key(['up'], () => {
613
872
  if (modalOpen)
614
873
  return;
615
874
  if (focusPane === 'detail') {
@@ -621,12 +880,12 @@ export async function startTui(params) {
621
880
  widgets.screen.key(['pageup'], () => {
622
881
  if (modalOpen)
623
882
  return;
624
- scrollDetail(-12);
883
+ pageFocusedPane(-1);
625
884
  });
626
885
  widgets.screen.key(['pagedown'], () => {
627
886
  if (modalOpen)
628
887
  return;
629
- scrollDetail(12);
888
+ pageFocusedPane(1);
630
889
  });
631
890
  widgets.screen.key(['home'], () => {
632
891
  if (modalOpen)
@@ -693,6 +952,11 @@ export async function startTui(params) {
693
952
  return;
694
953
  promptFilter();
695
954
  });
955
+ widgets.screen.key(['#'], () => {
956
+ if (modalOpen)
957
+ return;
958
+ promptThreadJump();
959
+ });
696
960
  widgets.screen.key(['h', '?'], () => {
697
961
  if (modalOpen)
698
962
  return;
@@ -923,13 +1187,14 @@ export function buildHelpContent() {
923
1187
  '',
924
1188
  '{bold}Navigation{/bold}',
925
1189
  'Tab / Shift-Tab cycle focus across clusters, members, and detail',
926
- 'j / k move selection, or scroll detail when detail is focused',
927
- 'Up / Down same as j / k',
1190
+ 'Left / Right cycle focus backward or forward across panes',
1191
+ 'Up / Down move selection, or scroll detail when detail is focused',
928
1192
  'Enter clusters -> members, members -> detail',
929
- 'PgUp / PgDn scroll detail or this help popup faster',
1193
+ 'PgUp / PgDn page through the focused pane or this help popup faster',
930
1194
  'Home / End jump to the top or bottom of detail or help',
931
1195
  '',
932
1196
  '{bold}Views And Filters{/bold}',
1197
+ '# jump directly to an issue or PR number',
933
1198
  's cycle cluster sort mode',
934
1199
  'f cycle minimum cluster size filter',
935
1200
  'l toggle wide layout: columns vs. wide-left stacked-right',
@@ -938,20 +1203,20 @@ export function buildHelpContent() {
938
1203
  'r refresh the current local view from SQLite',
939
1204
  '',
940
1205
  '{bold}Actions{/bold}',
941
- 'g open the staged update pipeline (GitHub, embeddings, clusters)',
1206
+ 'g start the staged update pipeline in the background (GitHub, embeddings, clusters)',
942
1207
  'p open the repository browser / sync a new repository',
943
1208
  'u show all open threads for the selected author',
944
1209
  'o open the selected thread URL in your browser',
945
1210
  '',
946
1211
  '{bold}Help And Exit{/bold}',
947
1212
  'h or ? open this help popup',
948
- 'q quit the TUI (or close this popup)',
1213
+ 'q quit the TUI (or close this popup); warns if a background update is running',
949
1214
  'Esc close this popup',
950
1215
  '',
951
1216
  '{bold}Notes{/bold}',
952
1217
  'Clusters show C<clusterId> so the cluster id is easy to copy into CLI or skill flows.',
953
1218
  'The footer only shows the short command list. Open help to see the full list.',
954
- 'This popup scrolls. Use j/k, arrows, PgUp/PgDn, Home, and End if it does not fit.',
1219
+ 'This popup scrolls. Use arrows, PgUp/PgDn, Home, and End if it does not fit.',
955
1220
  ].join('\n');
956
1221
  }
957
1222
  async function promptHelp(screen) {
@@ -992,7 +1257,7 @@ async function promptHelp(screen) {
992
1257
  bottom: 1,
993
1258
  left: 'center',
994
1259
  tags: false,
995
- content: 'Scroll with j/k, arrows, PgUp/PgDn, Home, End. Press Esc, q, h, ?, or Enter to close.',
1260
+ content: 'Scroll with arrows, PgUp/PgDn, Home, End. Press Esc, q, h, ?, or Enter to close.',
996
1261
  style: { fg: 'black', bg: '#5bc0eb' },
997
1262
  });
998
1263
  box.focus();
@@ -1063,7 +1328,7 @@ async function promptUpdatePipelineSelection(screen, stats) {
1063
1328
  height: 4,
1064
1329
  style: { fg: 'white', bg: '#101522' },
1065
1330
  content: 'Usually you want all three. Run order is fixed: GitHub sync/reconcile -> embeddings -> clusters.\n' +
1066
- 'Toggle with space, move with j/k or arrows, Enter to start, Esc to cancel.',
1331
+ 'Toggle with space, move with arrows, Enter to start, Esc to cancel.',
1067
1332
  });
1068
1333
  box.focus();
1069
1334
  box.select(0);
@@ -1285,7 +1550,7 @@ async function runColdStartSetup(service, screen, target, log, footer) {
1285
1550
  repo: target.repo,
1286
1551
  onProgress: reporter,
1287
1552
  });
1288
- service.clusterRepository({
1553
+ await service.clusterRepository({
1289
1554
  owner: target.owner,
1290
1555
  repo: target.repo,
1291
1556
  onProgress: reporter,