@in-the-loop-labs/pair-review 2.4.3 → 2.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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +51 -7
- package/public/js/components/ChatPanel.js +79 -1
- package/public/js/index.js +19 -16
- package/public/js/pr.js +36 -2
- package/src/chat/chat-providers.js +36 -9
- package/src/config.js +45 -3
- package/src/main.js +10 -5
- package/src/routes/config.js +1 -1
- package/src/routes/pr.js +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
package/public/css/pr.css
CHANGED
|
@@ -1188,8 +1188,7 @@
|
|
|
1188
1188
|
background: var(--color-bg-primary);
|
|
1189
1189
|
border: 1px solid var(--color-border-primary);
|
|
1190
1190
|
border-radius: 8px;
|
|
1191
|
-
overflow
|
|
1192
|
-
overflow-y: visible;
|
|
1191
|
+
overflow: visible; /* Must be visible so sticky file headers work relative to .diff-view */
|
|
1193
1192
|
position: relative;
|
|
1194
1193
|
min-width: 0;
|
|
1195
1194
|
padding-bottom: 1px;
|
|
@@ -1335,7 +1334,7 @@
|
|
|
1335
1334
|
/* Diff styling improvements */
|
|
1336
1335
|
.d2h-file-wrapper[data-file-name] {
|
|
1337
1336
|
margin-bottom: 24px;
|
|
1338
|
-
overflow-x:
|
|
1337
|
+
overflow-x: visible; /* Must be visible for sticky file headers to work */
|
|
1339
1338
|
max-width: 100%;
|
|
1340
1339
|
}
|
|
1341
1340
|
|
|
@@ -1348,6 +1347,9 @@
|
|
|
1348
1347
|
border-bottom: 1px solid var(--color-border-primary);
|
|
1349
1348
|
font-weight: 600;
|
|
1350
1349
|
font-size: 14px;
|
|
1350
|
+
position: sticky;
|
|
1351
|
+
top: var(--toolbar-height, 0px);
|
|
1352
|
+
z-index: 4;
|
|
1351
1353
|
}
|
|
1352
1354
|
|
|
1353
1355
|
.d2h-file-name {
|
|
@@ -1540,7 +1542,8 @@
|
|
|
1540
1542
|
color: #f85149;
|
|
1541
1543
|
}
|
|
1542
1544
|
|
|
1543
|
-
/* Hide diff
|
|
1545
|
+
/* Hide diff content when collapsed */
|
|
1546
|
+
.d2h-file-wrapper.collapsed .d2h-file-body,
|
|
1544
1547
|
.d2h-file-wrapper.collapsed .d2h-diff-table {
|
|
1545
1548
|
display: none;
|
|
1546
1549
|
}
|
|
@@ -1560,6 +1563,12 @@
|
|
|
1560
1563
|
cursor: pointer;
|
|
1561
1564
|
}
|
|
1562
1565
|
|
|
1566
|
+
/* Scrollable wrapper for diff tables — provides per-file horizontal scroll
|
|
1567
|
+
now that .d2h-file-wrapper and .diff-container use overflow:visible for sticky headers */
|
|
1568
|
+
.d2h-file-body {
|
|
1569
|
+
overflow-x: auto;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1563
1572
|
.d2h-diff-table {
|
|
1564
1573
|
width: 100%;
|
|
1565
1574
|
border-collapse: collapse;
|
|
@@ -6288,6 +6297,9 @@ body:not([data-theme="dark"]) .theme-icon-light {
|
|
|
6288
6297
|
background: var(--color-bg-primary);
|
|
6289
6298
|
border-bottom: 1px solid var(--color-border-primary);
|
|
6290
6299
|
flex-shrink: 0;
|
|
6300
|
+
position: sticky;
|
|
6301
|
+
top: 0;
|
|
6302
|
+
z-index: 5;
|
|
6291
6303
|
}
|
|
6292
6304
|
|
|
6293
6305
|
.diff-toolbar .sidebar-toggle-collapsed {
|
|
@@ -6759,11 +6771,10 @@ body:not([data-theme="dark"]) .theme-icon-light {
|
|
|
6759
6771
|
}
|
|
6760
6772
|
|
|
6761
6773
|
.main-layout .diff-container {
|
|
6762
|
-
flex: 1;
|
|
6774
|
+
flex: 1 0 auto; /* Grow to fill, but don't shrink below content so .diff-view scrolls */
|
|
6763
6775
|
min-width: 0; /* Prevent flex item from expanding beyond container */
|
|
6764
6776
|
padding: 16px;
|
|
6765
|
-
overflow
|
|
6766
|
-
overflow-x: auto; /* Allow scroll as fallback for unbreakable content */
|
|
6777
|
+
overflow: visible; /* Must be visible so sticky file headers work relative to .diff-view */
|
|
6767
6778
|
}
|
|
6768
6779
|
|
|
6769
6780
|
/* --------------------------------------------------------------------------
|
|
@@ -11055,6 +11066,39 @@ body.resizing * {
|
|
|
11055
11066
|
background: linear-gradient(to bottom, var(--chat-subtle), transparent);
|
|
11056
11067
|
flex-shrink: 0;
|
|
11057
11068
|
}
|
|
11069
|
+
|
|
11070
|
+
/* Status flash pill — transient notification between header and content */
|
|
11071
|
+
.chat-panel__status-flash {
|
|
11072
|
+
display: flex;
|
|
11073
|
+
justify-content: center;
|
|
11074
|
+
padding: 6px 16px;
|
|
11075
|
+
flex-shrink: 0;
|
|
11076
|
+
opacity: 0;
|
|
11077
|
+
transition: opacity 0.3s ease;
|
|
11078
|
+
}
|
|
11079
|
+
|
|
11080
|
+
.chat-panel__status-flash--visible {
|
|
11081
|
+
opacity: 1;
|
|
11082
|
+
}
|
|
11083
|
+
|
|
11084
|
+
.chat-panel__status-flash-text {
|
|
11085
|
+
display: inline-block;
|
|
11086
|
+
padding: 3px 12px;
|
|
11087
|
+
font-size: 11px;
|
|
11088
|
+
font-weight: 600;
|
|
11089
|
+
letter-spacing: 0.02em;
|
|
11090
|
+
color: #92400e;
|
|
11091
|
+
background: #fef3c7;
|
|
11092
|
+
border: 1px solid #fcd34d;
|
|
11093
|
+
border-radius: 999px;
|
|
11094
|
+
}
|
|
11095
|
+
|
|
11096
|
+
[data-theme="dark"] .chat-panel__status-flash-text {
|
|
11097
|
+
color: #fbbf24;
|
|
11098
|
+
background: rgba(251, 191, 36, 0.12);
|
|
11099
|
+
border-color: rgba(251, 191, 36, 0.3);
|
|
11100
|
+
}
|
|
11101
|
+
|
|
11058
11102
|
/* Provider picker */
|
|
11059
11103
|
.chat-panel__provider-picker {
|
|
11060
11104
|
position: relative;
|
|
@@ -32,6 +32,7 @@ class ChatPanel {
|
|
|
32
32
|
this._analysisContextRemoved = false;
|
|
33
33
|
this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
|
|
34
34
|
this._openPromise = null; // concurrency guard for open()
|
|
35
|
+
this._sessionWarm = false; // true once the session has been used in this page load
|
|
35
36
|
this._activeProvider = window.__pairReview?.chatProvider || 'pi';
|
|
36
37
|
this._chatProviders = window.__pairReview?.chatProviders || [];
|
|
37
38
|
|
|
@@ -84,6 +85,9 @@ class ChatPanel {
|
|
|
84
85
|
</button>
|
|
85
86
|
</div>
|
|
86
87
|
</div>
|
|
88
|
+
<div class="chat-panel__status-flash" style="display:none">
|
|
89
|
+
<span class="chat-panel__status-flash-text">Starting Agent Client Protocol</span>
|
|
90
|
+
</div>
|
|
87
91
|
<div class="chat-panel__messages-wrapper">
|
|
88
92
|
<div class="chat-panel__messages" id="chat-messages">
|
|
89
93
|
<div class="chat-panel__empty">
|
|
@@ -170,6 +174,7 @@ class ChatPanel {
|
|
|
170
174
|
this.historyBtn = this.container.querySelector('.chat-panel__history-btn');
|
|
171
175
|
this.titleTextEl = this.container.querySelector('.chat-panel__title-text');
|
|
172
176
|
this.newContentPill = this.container.querySelector('.chat-panel__new-content-pill');
|
|
177
|
+
this.statusFlash = this.container.querySelector('.chat-panel__status-flash');
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
/**
|
|
@@ -407,6 +412,53 @@ class ChatPanel {
|
|
|
407
412
|
return providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
|
408
413
|
}
|
|
409
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Check if the active provider uses ACP (Agent Client Protocol).
|
|
417
|
+
* @returns {boolean}
|
|
418
|
+
*/
|
|
419
|
+
_isAcpProvider() {
|
|
420
|
+
const entry = this._chatProviders.find(p => p.id === this._activeProvider);
|
|
421
|
+
return entry?.type === 'acp';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Show a transient status flash pill (e.g. "Starting Agent Client Protocol").
|
|
426
|
+
* Auto-hides after the given timeout.
|
|
427
|
+
* @param {string} text - Text to display
|
|
428
|
+
* @param {number} [timeout=5000] - Max display time in ms
|
|
429
|
+
*/
|
|
430
|
+
_showStatusFlash(text, timeout = 5000) {
|
|
431
|
+
if (!this.statusFlash) return;
|
|
432
|
+
if (this._hideAnimationTimeout) {
|
|
433
|
+
clearTimeout(this._hideAnimationTimeout);
|
|
434
|
+
this._hideAnimationTimeout = null;
|
|
435
|
+
}
|
|
436
|
+
const textEl = this.statusFlash.querySelector('.chat-panel__status-flash-text');
|
|
437
|
+
if (textEl) textEl.textContent = text;
|
|
438
|
+
this.statusFlash.style.display = '';
|
|
439
|
+
// Force reflow to ensure the fade-in animation triggers
|
|
440
|
+
void this.statusFlash.offsetHeight;
|
|
441
|
+
this.statusFlash.classList.add('chat-panel__status-flash--visible');
|
|
442
|
+
this._statusFlashTimeout = setTimeout(() => this._hideStatusFlash(), timeout);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Hide the status flash pill with a fade-out animation.
|
|
447
|
+
*/
|
|
448
|
+
_hideStatusFlash() {
|
|
449
|
+
if (this._statusFlashTimeout) {
|
|
450
|
+
clearTimeout(this._statusFlashTimeout);
|
|
451
|
+
this._statusFlashTimeout = null;
|
|
452
|
+
}
|
|
453
|
+
if (!this.statusFlash) return;
|
|
454
|
+
this.statusFlash.classList.remove('chat-panel__status-flash--visible');
|
|
455
|
+
// Hide after transition completes
|
|
456
|
+
this._hideAnimationTimeout = setTimeout(() => {
|
|
457
|
+
if (this.statusFlash) this.statusFlash.style.display = 'none';
|
|
458
|
+
this._hideAnimationTimeout = null;
|
|
459
|
+
}, 300);
|
|
460
|
+
}
|
|
461
|
+
|
|
410
462
|
/**
|
|
411
463
|
* Open the chat panel
|
|
412
464
|
* @param {Object} options - Optional context
|
|
@@ -666,6 +718,7 @@ class ChatPanel {
|
|
|
666
718
|
|
|
667
719
|
const mru = sessions[0];
|
|
668
720
|
this.currentSessionId = mru.id;
|
|
721
|
+
this._sessionWarm = false;
|
|
669
722
|
this._resubscribeChat();
|
|
670
723
|
console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
|
|
671
724
|
|
|
@@ -944,6 +997,7 @@ class ChatPanel {
|
|
|
944
997
|
|
|
945
998
|
// 2. Reset state
|
|
946
999
|
this.currentSessionId = sessionId;
|
|
1000
|
+
this._sessionWarm = false;
|
|
947
1001
|
this._resubscribeChat();
|
|
948
1002
|
this.messages = [];
|
|
949
1003
|
this._streamingContent = '';
|
|
@@ -1081,6 +1135,11 @@ class ChatPanel {
|
|
|
1081
1135
|
return null;
|
|
1082
1136
|
}
|
|
1083
1137
|
|
|
1138
|
+
const isAcp = this._isAcpProvider();
|
|
1139
|
+
if (isAcp) {
|
|
1140
|
+
this._showStatusFlash('Starting Agent Client Protocol');
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1084
1143
|
try {
|
|
1085
1144
|
const body = {
|
|
1086
1145
|
provider: this._activeProvider,
|
|
@@ -1100,6 +1159,8 @@ class ChatPanel {
|
|
|
1100
1159
|
body: JSON.stringify(body)
|
|
1101
1160
|
});
|
|
1102
1161
|
|
|
1162
|
+
if (isAcp) this._hideStatusFlash();
|
|
1163
|
+
|
|
1103
1164
|
if (!response.ok) {
|
|
1104
1165
|
const err = await response.json().catch(() => ({}));
|
|
1105
1166
|
throw new Error(err.error || 'Failed to create chat session');
|
|
@@ -1107,10 +1168,12 @@ class ChatPanel {
|
|
|
1107
1168
|
|
|
1108
1169
|
const result = await response.json();
|
|
1109
1170
|
this.currentSessionId = result.data.id;
|
|
1171
|
+
this._sessionWarm = true;
|
|
1110
1172
|
this._resubscribeChat();
|
|
1111
1173
|
console.debug('[ChatPanel] Session created:', this.currentSessionId);
|
|
1112
1174
|
return result.data;
|
|
1113
1175
|
} catch (error) {
|
|
1176
|
+
if (isAcp) this._hideStatusFlash();
|
|
1114
1177
|
console.error('[ChatPanel] Error creating session:', error);
|
|
1115
1178
|
this._showError('Failed to start chat session. ' + error.message);
|
|
1116
1179
|
return null;
|
|
@@ -1197,6 +1260,12 @@ class ChatPanel {
|
|
|
1197
1260
|
this._pendingActionContext = null;
|
|
1198
1261
|
}
|
|
1199
1262
|
|
|
1263
|
+
// Show ACP resume flash when the session may need server-side auto-resume
|
|
1264
|
+
const acpResuming = this._isAcpProvider() && !this._sessionWarm;
|
|
1265
|
+
if (acpResuming) {
|
|
1266
|
+
this._showStatusFlash('Resuming Agent Client Protocol');
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1200
1269
|
// Send to API
|
|
1201
1270
|
try {
|
|
1202
1271
|
console.debug('[ChatPanel] Sending message to session', this.currentSessionId);
|
|
@@ -1206,7 +1275,15 @@ class ChatPanel {
|
|
|
1206
1275
|
body: JSON.stringify(payload)
|
|
1207
1276
|
});
|
|
1208
1277
|
|
|
1209
|
-
|
|
1278
|
+
if (acpResuming) {
|
|
1279
|
+
this._hideStatusFlash();
|
|
1280
|
+
this._sessionWarm = true;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Handle 410 Gone: session is not resumable — transparently create a new one and retry once.
|
|
1284
|
+
// Note: we do NOT call _hideStatusFlash() here. createSession() will call
|
|
1285
|
+
// _showStatusFlash() which overwrites the pill text directly, avoiding a
|
|
1286
|
+
// visible hide/show flicker during the transparent retry.
|
|
1210
1287
|
if (response.status === 410) {
|
|
1211
1288
|
console.debug('[ChatPanel] Session not resumable (410), creating new session and retrying');
|
|
1212
1289
|
this.currentSessionId = null;
|
|
@@ -1230,6 +1307,7 @@ class ChatPanel {
|
|
|
1230
1307
|
}
|
|
1231
1308
|
console.debug('[ChatPanel] Message accepted, waiting for WebSocket events');
|
|
1232
1309
|
} catch (error) {
|
|
1310
|
+
if (acpResuming) this._hideStatusFlash();
|
|
1233
1311
|
// Restore pending context so it's not lost
|
|
1234
1312
|
this._pendingContext = savedContext;
|
|
1235
1313
|
this._pendingContextData = savedContextData;
|
package/public/js/index.js
CHANGED
|
@@ -402,11 +402,6 @@
|
|
|
402
402
|
state.fetchedAt = data.fetched_at;
|
|
403
403
|
|
|
404
404
|
renderCollectionTable(container, state, collection);
|
|
405
|
-
|
|
406
|
-
// Auto-refresh on first load if cache is empty
|
|
407
|
-
if (state.prs.length === 0 && !state.fetchedAt) {
|
|
408
|
-
refreshCollectionPrs(collection, containerId, state);
|
|
409
|
-
}
|
|
410
405
|
} catch (error) {
|
|
411
406
|
console.error('Error loading ' + collection + ':', error);
|
|
412
407
|
container.innerHTML =
|
|
@@ -1164,11 +1159,11 @@
|
|
|
1164
1159
|
if (collectionRow && !event.target.closest('a')) {
|
|
1165
1160
|
var prUrl = collectionRow.dataset.prUrl;
|
|
1166
1161
|
if (prUrl) {
|
|
1167
|
-
// Switch to PR tab to show loading state
|
|
1162
|
+
// Switch to PR tab to show loading state (do NOT persist to
|
|
1163
|
+
// localStorage – the user's intentional tab choice should be preserved)
|
|
1168
1164
|
var tabBar = document.getElementById('unified-tab-bar');
|
|
1169
1165
|
var prTabBtn = tabBar.querySelector('[data-tab="pr-tab"]');
|
|
1170
1166
|
switchTab(tabBar, prTabBtn);
|
|
1171
|
-
localStorage.setItem(TAB_STORAGE_KEY, 'pr-tab');
|
|
1172
1167
|
|
|
1173
1168
|
// Populate input and submit the form programmatically
|
|
1174
1169
|
var input = document.getElementById('pr-url-input');
|
|
@@ -1204,19 +1199,25 @@
|
|
|
1204
1199
|
const unifiedTabBtn = event.target.closest('#unified-tab-bar .tab-btn');
|
|
1205
1200
|
if (unifiedTabBtn) {
|
|
1206
1201
|
const tabBar = document.getElementById('unified-tab-bar');
|
|
1207
|
-
switchTab(tabBar, unifiedTabBtn, function (tabId) {
|
|
1202
|
+
switchTab(tabBar, unifiedTabBtn, async function (tabId) {
|
|
1208
1203
|
// Persist tab choice
|
|
1209
1204
|
localStorage.setItem(TAB_STORAGE_KEY, tabId);
|
|
1210
1205
|
// Lazy-load local reviews on first switch
|
|
1211
1206
|
if (tabId === 'local-tab' && !localReviewsPagination.loaded) {
|
|
1212
1207
|
loadLocalReviews();
|
|
1213
1208
|
}
|
|
1214
|
-
//
|
|
1215
|
-
if (tabId === 'review-requests-tab'
|
|
1216
|
-
|
|
1209
|
+
// Load cached data on first switch, then always refresh from GitHub
|
|
1210
|
+
if (tabId === 'review-requests-tab') {
|
|
1211
|
+
if (!reviewRequestsState.loaded) {
|
|
1212
|
+
await loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
|
|
1213
|
+
}
|
|
1214
|
+
refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
|
|
1217
1215
|
}
|
|
1218
|
-
if (tabId === 'my-prs-tab'
|
|
1219
|
-
|
|
1216
|
+
if (tabId === 'my-prs-tab') {
|
|
1217
|
+
if (!myPrsState.loaded) {
|
|
1218
|
+
await loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
|
|
1219
|
+
}
|
|
1220
|
+
refreshCollectionPrs('my-prs', 'my-prs-container', myPrsState);
|
|
1220
1221
|
}
|
|
1221
1222
|
});
|
|
1222
1223
|
return;
|
|
@@ -1248,12 +1249,14 @@
|
|
|
1248
1249
|
loadLocalReviews();
|
|
1249
1250
|
}
|
|
1250
1251
|
|
|
1251
|
-
// If a GitHub collection tab is active, load
|
|
1252
|
+
// If a GitHub collection tab is active, load cached data then refresh from GitHub
|
|
1252
1253
|
if (savedTab === 'review-requests-tab') {
|
|
1253
|
-
loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState)
|
|
1254
|
+
loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState)
|
|
1255
|
+
.then(function () { refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState); });
|
|
1254
1256
|
}
|
|
1255
1257
|
if (savedTab === 'my-prs-tab') {
|
|
1256
|
-
loadCollectionPrs('my-prs', 'my-prs-container', myPrsState)
|
|
1258
|
+
loadCollectionPrs('my-prs', 'my-prs-container', myPrsState)
|
|
1259
|
+
.then(function () { refreshCollectionPrs('my-prs', 'my-prs-container', myPrsState); });
|
|
1257
1260
|
}
|
|
1258
1261
|
|
|
1259
1262
|
// Set up start review form handler
|
package/public/js/pr.js
CHANGED
|
@@ -180,6 +180,9 @@ class PRManager {
|
|
|
180
180
|
this.initAnalysisConfigModal();
|
|
181
181
|
this.initKeyboardShortcuts();
|
|
182
182
|
|
|
183
|
+
// Track toolbar height for sticky file headers (they sit below the sticky toolbar)
|
|
184
|
+
this._initToolbarHeightTracking();
|
|
185
|
+
|
|
183
186
|
// Initialize diff options dropdown (gear icon for whitespace toggle).
|
|
184
187
|
// Must happen before init() so the persisted hideWhitespace state is
|
|
185
188
|
// applied before the first loadAndDisplayFiles() call.
|
|
@@ -238,6 +241,27 @@ class PRManager {
|
|
|
238
241
|
};
|
|
239
242
|
}
|
|
240
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Keep --toolbar-height CSS variable in sync with the actual toolbar size
|
|
246
|
+
* so sticky file headers can position themselves below the sticky toolbar.
|
|
247
|
+
*/
|
|
248
|
+
_initToolbarHeightTracking() {
|
|
249
|
+
const toolbar = document.querySelector('.diff-toolbar');
|
|
250
|
+
if (!toolbar) return;
|
|
251
|
+
|
|
252
|
+
const update = () => {
|
|
253
|
+
document.documentElement.style.setProperty(
|
|
254
|
+
'--toolbar-height', toolbar.offsetHeight + 'px'
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
update();
|
|
258
|
+
|
|
259
|
+
// Re-measure when toolbar resizes (e.g. analysis dots appear/disappear)
|
|
260
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
261
|
+
new ResizeObserver(update).observe(toolbar);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
241
265
|
/**
|
|
242
266
|
* Set up event handlers
|
|
243
267
|
*/
|
|
@@ -1182,7 +1206,13 @@ class PRManager {
|
|
|
1182
1206
|
}
|
|
1183
1207
|
|
|
1184
1208
|
table.appendChild(tbody);
|
|
1185
|
-
|
|
1209
|
+
|
|
1210
|
+
// Wrap table in a scrollable container for horizontal scroll of long code lines
|
|
1211
|
+
// (parent elements use overflow:visible to support sticky file headers)
|
|
1212
|
+
const fileBody = document.createElement('div');
|
|
1213
|
+
fileBody.className = 'd2h-file-body';
|
|
1214
|
+
fileBody.appendChild(table);
|
|
1215
|
+
wrapper.appendChild(fileBody);
|
|
1186
1216
|
|
|
1187
1217
|
return wrapper;
|
|
1188
1218
|
}
|
|
@@ -4784,7 +4814,11 @@ class PRManager {
|
|
|
4784
4814
|
table.className = 'd2h-diff-table';
|
|
4785
4815
|
const tbody = this._buildContextChunkTbody(data, contextFile);
|
|
4786
4816
|
table.appendChild(tbody);
|
|
4787
|
-
|
|
4817
|
+
|
|
4818
|
+
const fileBody = document.createElement('div');
|
|
4819
|
+
fileBody.className = 'd2h-file-body';
|
|
4820
|
+
fileBody.appendChild(table);
|
|
4821
|
+
wrapper.appendChild(fileBody);
|
|
4788
4822
|
|
|
4789
4823
|
// Insert in sorted path order among existing file wrappers
|
|
4790
4824
|
const allWrappers = [...diffContainer.querySelectorAll('.d2h-file-wrapper')];
|
|
@@ -96,17 +96,40 @@ function applyConfigOverrides(providersConfig) {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Get a chat provider definition with config overrides merged.
|
|
99
|
-
*
|
|
99
|
+
* Supports both built-in providers and dynamic providers defined entirely in config.
|
|
100
|
+
* @param {string} id - Provider ID (e.g. 'copilot-acp', or a custom ID like 'river')
|
|
100
101
|
* @returns {Object|null} Provider definition or null if unknown
|
|
101
102
|
*/
|
|
102
103
|
function getChatProvider(id) {
|
|
103
104
|
const base = CHAT_PROVIDERS[id];
|
|
104
|
-
if (!base) return null;
|
|
105
|
-
|
|
106
105
|
const overrides = _configOverrides[id];
|
|
106
|
+
|
|
107
|
+
if (!base && !overrides) return null;
|
|
108
|
+
|
|
109
|
+
// Dynamic provider defined entirely in config
|
|
110
|
+
if (!base) {
|
|
111
|
+
const provider = {
|
|
112
|
+
id,
|
|
113
|
+
name: overrides.name || overrides.label || id,
|
|
114
|
+
type: overrides.type || 'acp',
|
|
115
|
+
command: overrides.command || id,
|
|
116
|
+
args: overrides.args || [],
|
|
117
|
+
env: overrides.env || {},
|
|
118
|
+
};
|
|
119
|
+
if (overrides.model) provider.model = overrides.model;
|
|
120
|
+
if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
|
|
121
|
+
provider.args = [...provider.args, ...overrides.extra_args];
|
|
122
|
+
}
|
|
123
|
+
if (provider.command.includes(' ')) {
|
|
124
|
+
provider.useShell = true;
|
|
125
|
+
}
|
|
126
|
+
return provider;
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
if (!overrides) return { ...base };
|
|
108
130
|
|
|
109
131
|
const merged = { ...base };
|
|
132
|
+
if (overrides.name || overrides.label) merged.name = overrides.name || overrides.label;
|
|
110
133
|
if (overrides.command) merged.command = overrides.command;
|
|
111
134
|
if (overrides.model) merged.model = overrides.model;
|
|
112
135
|
if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
|
|
@@ -125,11 +148,15 @@ function getChatProvider(id) {
|
|
|
125
148
|
}
|
|
126
149
|
|
|
127
150
|
/**
|
|
128
|
-
* Get all chat provider definitions (
|
|
151
|
+
* Get all chat provider definitions (built-in + dynamic from config).
|
|
129
152
|
* @returns {Array<Object>}
|
|
130
153
|
*/
|
|
131
154
|
function getAllChatProviders() {
|
|
132
|
-
|
|
155
|
+
const ids = new Set([
|
|
156
|
+
...Object.keys(CHAT_PROVIDERS),
|
|
157
|
+
...Object.keys(_configOverrides),
|
|
158
|
+
]);
|
|
159
|
+
return [...ids].map(id => getChatProvider(id)).filter(Boolean);
|
|
133
160
|
}
|
|
134
161
|
|
|
135
162
|
/**
|
|
@@ -138,7 +165,7 @@ function getAllChatProviders() {
|
|
|
138
165
|
* @returns {boolean}
|
|
139
166
|
*/
|
|
140
167
|
function isAcpProvider(id) {
|
|
141
|
-
const provider =
|
|
168
|
+
const provider = getChatProvider(id);
|
|
142
169
|
return provider?.type === 'acp';
|
|
143
170
|
}
|
|
144
171
|
|
|
@@ -148,7 +175,7 @@ function isAcpProvider(id) {
|
|
|
148
175
|
* @returns {boolean}
|
|
149
176
|
*/
|
|
150
177
|
function isClaudeCodeProvider(id) {
|
|
151
|
-
const provider =
|
|
178
|
+
const provider = getChatProvider(id);
|
|
152
179
|
return provider?.type === 'claude';
|
|
153
180
|
}
|
|
154
181
|
|
|
@@ -158,7 +185,7 @@ function isClaudeCodeProvider(id) {
|
|
|
158
185
|
* @returns {boolean}
|
|
159
186
|
*/
|
|
160
187
|
function isCodexProvider(id) {
|
|
161
|
-
const provider =
|
|
188
|
+
const provider = getChatProvider(id);
|
|
162
189
|
return provider?.type === 'codex';
|
|
163
190
|
}
|
|
164
191
|
|
|
@@ -223,7 +250,7 @@ async function checkChatProviderAvailability(id, _deps) {
|
|
|
223
250
|
* @returns {Promise<void>}
|
|
224
251
|
*/
|
|
225
252
|
async function checkAllChatProviders(_deps) {
|
|
226
|
-
const ids = Object.keys(CHAT_PROVIDERS);
|
|
253
|
+
const ids = [...new Set([...Object.keys(CHAT_PROVIDERS), ...Object.keys(_configOverrides)])];
|
|
227
254
|
const results = await Promise.all(
|
|
228
255
|
ids.map(async (id) => {
|
|
229
256
|
const result = await checkChatProviderAvailability(id, _deps);
|
package/src/config.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
const fs = require('fs').promises;
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
|
+
const childProcess = require('child_process');
|
|
5
6
|
const logger = require('./utils/logger');
|
|
6
7
|
|
|
8
|
+
let _cachedCommandToken = null;
|
|
9
|
+
|
|
7
10
|
const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
|
|
8
11
|
const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
|
|
9
12
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
@@ -13,6 +16,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
|
13
16
|
|
|
14
17
|
const DEFAULT_CONFIG = {
|
|
15
18
|
github_token: "",
|
|
19
|
+
github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
|
|
16
20
|
port: 7247,
|
|
17
21
|
theme: "light",
|
|
18
22
|
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
@@ -247,17 +251,54 @@ function getConfigDir() {
|
|
|
247
251
|
* Priority:
|
|
248
252
|
* 1. GITHUB_TOKEN environment variable (highest priority)
|
|
249
253
|
* 2. config.github_token from ~/.pair-review/config.json
|
|
254
|
+
* 3. config.github_token_command — execute shell command, use stdout (cached on success)
|
|
255
|
+
* 4. Empty string (no token)
|
|
250
256
|
*
|
|
251
257
|
* @param {Object} config - Configuration object from loadConfig()
|
|
252
258
|
* @returns {string} - GitHub token or empty string if not configured
|
|
253
259
|
*/
|
|
254
260
|
function getGitHubToken(config) {
|
|
255
|
-
// Environment variable takes precedence
|
|
256
261
|
if (process.env.GITHUB_TOKEN) {
|
|
262
|
+
logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
|
|
257
263
|
return process.env.GITHUB_TOKEN;
|
|
258
264
|
}
|
|
259
|
-
|
|
260
|
-
|
|
265
|
+
if (config.github_token) {
|
|
266
|
+
logger.debug('Using GitHub token from config.github_token');
|
|
267
|
+
return config.github_token;
|
|
268
|
+
}
|
|
269
|
+
if (config.github_token_command) {
|
|
270
|
+
if (_cachedCommandToken !== null) {
|
|
271
|
+
logger.debug('Using GitHub token from github_token_command (cached)');
|
|
272
|
+
return _cachedCommandToken;
|
|
273
|
+
}
|
|
274
|
+
logger.debug(`Attempting GitHub token from command: ${config.github_token_command}`);
|
|
275
|
+
try {
|
|
276
|
+
const result = childProcess.execSync(config.github_token_command, {
|
|
277
|
+
encoding: 'utf8',
|
|
278
|
+
timeout: 5000,
|
|
279
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
280
|
+
}).trim();
|
|
281
|
+
if (!result) {
|
|
282
|
+
logger.warn(`github_token_command did not produce a token (command: ${config.github_token_command})`);
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
logger.debug('Using GitHub token from github_token_command');
|
|
286
|
+
_cachedCommandToken = result;
|
|
287
|
+
return result;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
logger.warn(`github_token_command failed (command: ${config.github_token_command}): ${error.message}`);
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
logger.debug('No GitHub token configured');
|
|
294
|
+
return '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Resets the cached command token. Exported for testing only.
|
|
299
|
+
*/
|
|
300
|
+
function _resetTokenCache() {
|
|
301
|
+
_cachedCommandToken = null;
|
|
261
302
|
}
|
|
262
303
|
|
|
263
304
|
/**
|
|
@@ -465,5 +506,6 @@ module.exports = {
|
|
|
465
506
|
resolveMonorepoOptions,
|
|
466
507
|
resolveDbName,
|
|
467
508
|
warnIfDevModeWithoutDbName,
|
|
509
|
+
_resetTokenCache,
|
|
468
510
|
DEFAULT_CHECKOUT_TIMEOUT_MS
|
|
469
511
|
};
|
package/src/main.js
CHANGED
|
@@ -318,7 +318,7 @@ CONFIG FILE:
|
|
|
318
318
|
|
|
319
319
|
Example config:
|
|
320
320
|
{
|
|
321
|
-
"
|
|
321
|
+
"github_token_command": "gh auth token",
|
|
322
322
|
"port": 7247,
|
|
323
323
|
"theme": "light",
|
|
324
324
|
"debug_stream": false,
|
|
@@ -327,7 +327,10 @@ CONFIG FILE:
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
GITHUB TOKEN:
|
|
330
|
-
|
|
330
|
+
If you have the GitHub CLI (gh) installed and authenticated,
|
|
331
|
+
you're all set — the default github_token_command handles it.
|
|
332
|
+
|
|
333
|
+
Otherwise, create a Personal Access Token at:
|
|
331
334
|
https://github.com/settings/tokens/new
|
|
332
335
|
|
|
333
336
|
Required scopes:
|
|
@@ -336,7 +339,9 @@ GITHUB TOKEN:
|
|
|
336
339
|
|
|
337
340
|
You can provide the token via:
|
|
338
341
|
1. GITHUB_TOKEN environment variable (takes precedence)
|
|
339
|
-
2. github_token field in config file
|
|
342
|
+
2. github_token field in config file (**deprecated**)
|
|
343
|
+
3. github_token_command in config file (**preferred** for security, default: "gh auth token")
|
|
344
|
+
No secret stored in plain text. Works with gh CLI, 1Password CLI, pass, etc.
|
|
340
345
|
|
|
341
346
|
ENVIRONMENT VARIABLES:
|
|
342
347
|
GITHUB_TOKEN GitHub Personal Access Token (takes precedence over config file)
|
|
@@ -504,7 +509,7 @@ async function handlePullRequest(args, config, db, flags = {}) {
|
|
|
504
509
|
// Get GitHub token (env var takes precedence over config)
|
|
505
510
|
const githubToken = getGitHubToken(config);
|
|
506
511
|
if (!githubToken) {
|
|
507
|
-
throw new Error('GitHub token not found. Set GITHUB_TOKEN
|
|
512
|
+
throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
|
|
508
513
|
}
|
|
509
514
|
|
|
510
515
|
// Parse PR arguments
|
|
@@ -600,7 +605,7 @@ async function performHeadlessReview(args, config, db, flags, options) {
|
|
|
600
605
|
// Get GitHub token (env var takes precedence over config)
|
|
601
606
|
const githubToken = getGitHubToken(config);
|
|
602
607
|
if (!githubToken) {
|
|
603
|
-
throw new Error('GitHub token not found. Set GITHUB_TOKEN
|
|
608
|
+
throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
|
|
604
609
|
}
|
|
605
610
|
|
|
606
611
|
// Parse PR arguments
|
package/src/routes/config.js
CHANGED
|
@@ -37,7 +37,7 @@ router.get('/api/config', (req, res) => {
|
|
|
37
37
|
// Build chat_providers array with availability
|
|
38
38
|
const chatAvailability = getAllCachedChatAvailability();
|
|
39
39
|
const chatProviders = getAllChatProviders().map(p => ({
|
|
40
|
-
id: p.id, name: p.name, available: chatAvailability[p.id]?.available || false
|
|
40
|
+
id: p.id, name: p.name, type: p.type, available: chatAvailability[p.id]?.available || false
|
|
41
41
|
}));
|
|
42
42
|
|
|
43
43
|
// Only return safe configuration values (not secrets like github_token)
|
package/src/routes/pr.js
CHANGED
|
@@ -300,7 +300,11 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
// Fetch fresh PR data from GitHub
|
|
303
|
-
const
|
|
303
|
+
const githubToken = getGitHubToken(config);
|
|
304
|
+
if (!githubToken) {
|
|
305
|
+
return res.status(401).json({ error: 'GitHub token not configured' });
|
|
306
|
+
}
|
|
307
|
+
const githubClient = new GitHubClient(githubToken);
|
|
304
308
|
const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
305
309
|
|
|
306
310
|
// Update worktree with latest changes
|
|
@@ -467,7 +471,11 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
|
|
|
467
471
|
}
|
|
468
472
|
|
|
469
473
|
// Fetch current PR from GitHub
|
|
470
|
-
const
|
|
474
|
+
const githubToken = getGitHubToken(config);
|
|
475
|
+
if (!githubToken) {
|
|
476
|
+
return res.json({ isStale: null, error: 'GitHub token not configured' });
|
|
477
|
+
}
|
|
478
|
+
const githubClient = new GitHubClient(githubToken);
|
|
471
479
|
const remotePrData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
472
480
|
|
|
473
481
|
const remoteHeadSha = remotePrData.head_sha;
|