reviw 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/cli.cjs +247 -36
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Multiple files can be specified. Each file opens on a separate port.
9
9
  * Click cells in the browser to add comments.
10
- * Close the tab or click "Submit & Exit" to send comments to the server.
10
+ * Click "Submit & Exit" to send comments to the server.
11
11
  * When all files are closed, outputs combined YAML to stdout and exits.
12
12
  */
13
13
 
@@ -1871,7 +1871,7 @@ function diffHtmlTemplate(diffData, history = []) {
1871
1871
  <aside class="comment-list collapsed">
1872
1872
  <h3>Comments</h3>
1873
1873
  <ol id="comment-list"></ol>
1874
- <p class="hint">Click "Submit & Exit" to finish review.</p>
1874
+ <p class="hint">Click "Submit & Exit" to finish review and output results.</p>
1875
1875
  </aside>
1876
1876
 
1877
1877
  <div class="modal-overlay" id="submit-modal">
@@ -2591,7 +2591,7 @@ function diffHtmlTemplate(diffData, history = []) {
2591
2591
  document.getElementById('modal-cancel').addEventListener('click', hideSubmitModal);
2592
2592
  async function doSubmit() {
2593
2593
  globalComment = globalCommentInput.value;
2594
- savePromptPrefs();
2594
+ if (typeof savePromptPrefs === 'function') savePromptPrefs();
2595
2595
  hideSubmitModal();
2596
2596
  await sendAndExit('button');
2597
2597
  // Try to close window; if it fails (browser security), show completion message
@@ -2956,6 +2956,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2956
2956
  td:hover:not(.selected) { background: var(--hover-bg); box-shadow: inset 0 0 0 1px rgba(96,165,250,0.25); }
2957
2957
  td.has-comment { background: rgba(34,197,94,0.12); box-shadow: inset 0 0 0 1px rgba(34,197,94,0.35); }
2958
2958
  td.selected, tbody th.selected { background: rgba(99,102,241,0.22) !important; box-shadow: inset 0 0 0 1px rgba(99,102,241,0.45); }
2959
+ .preview-highlight { background: rgba(167,139,250,0.18) !important; box-shadow: inset 0 0 0 2px rgba(139,92,246,0.35); border-radius: 4px; transition: background 150ms ease, box-shadow 150ms ease; padding-left: 8px; margin-left: -8px; }
2959
2960
  thead th.selected { background: #c7d2fe !important; box-shadow: inset 0 0 0 1px rgba(99,102,241,0.45); }
2960
2961
  [data-theme="dark"] thead th.selected { background: #3730a3 !important; }
2961
2962
  body.dragging { user-select: none; cursor: crosshair; }
@@ -3525,6 +3526,18 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3525
3526
  border-radius: 8px;
3526
3527
  line-height: 1.4;
3527
3528
  }
3529
+ .view-toggle {
3530
+ background: var(--panel);
3531
+ border: 1px solid var(--border);
3532
+ border-radius: 8px;
3533
+ padding: 6px 10px;
3534
+ cursor: pointer;
3535
+ color: var(--text);
3536
+ font-size: 14px;
3537
+ transition: background 0.15s ease, border-color 0.15s ease;
3538
+ }
3539
+ .view-toggle:hover { background: rgba(96,165,250,0.2); }
3540
+ .view-toggle.active { background: var(--accent); color: #fff; }
3528
3541
  @media (max-width: 1200px) {
3529
3542
  .media-sidebar-viewer.open {
3530
3543
  width: 40vw;
@@ -4431,10 +4444,13 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4431
4444
  }
4432
4445
  @media (max-width: 960px) {
4433
4446
  .md-layout { flex-direction: column; }
4434
- .md-left { max-width: 100%; flex: 0 0 auto; }
4447
+ .md-left { max-width: 100%; flex: 1 1 0; min-height: 0; }
4448
+ .md-right { display: none; }
4435
4449
  .media-sidebar { display: none; }
4436
4450
  .media-sidebar-toggle { display: none !important; }
4437
4451
  }
4452
+ .md-layout.preview-only .md-right { display: none; }
4453
+ .md-layout.preview-only .md-left { flex: 1 1 0; min-height: 0; max-width: 100%; }
4438
4454
  .filter-menu {
4439
4455
  position: absolute;
4440
4456
  background: var(--panel-solid);
@@ -4867,6 +4883,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4867
4883
  </div>
4868
4884
  <div class="actions">
4869
4885
  <button class="media-sidebar-toggle" id="media-sidebar-toggle" title="Media Gallery" aria-label="Toggle media gallery">🖼<span class="toggle-count" id="media-toggle-count"></span></button>
4886
+ <button class="view-toggle" id="view-toggle" title="Hide source panel" aria-label="Toggle source panel">📝</button>
4870
4887
  <button class="history-toggle" id="history-toggle" title="Review History">☰</button>
4871
4888
  <button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
4872
4889
  <span id="theme-icon">🌙</span>
@@ -4967,7 +4984,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4967
4984
  <aside class="comment-list collapsed">
4968
4985
  <h3>Comments</h3>
4969
4986
  <ol id="comment-list"></ol>
4970
- <p class="hint">Close the tab or click "Submit & Exit" to send comments and stop the server.</p>
4987
+ <p class="hint">Click "Submit & Exit" to send comments and stop the server.</p>
4971
4988
  </aside>
4972
4989
  <div class="filter-menu" id="filter-menu">
4973
4990
  <label class="menu-check"><input type="checkbox" id="freeze-col-check" /> Freeze up to this column</label>
@@ -6133,10 +6150,17 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6133
6150
  commentInput.value = existingComment?.text || '';
6134
6151
 
6135
6152
  card.style.display = 'block';
6136
- // 常にソーステーブルの選択セル位置を基準にカードを配置
6137
- // これにより、プレビューからクリックしてもソースからクリックしても
6138
- // 同じ行に対しては同じ位置にダイアログが表示される
6139
- positionCardForSelection(startRow, endRow, startCol, endCol);
6153
+ // Check if source panel is hidden (preview-only mode or narrow viewport)
6154
+ const mdRight = document.querySelector('.md-right');
6155
+ const isSourceHidden = mdRight && (mdRight.offsetParent === null || getComputedStyle(mdRight).display === 'none');
6156
+
6157
+ if (isSourceHidden && previewElement) {
6158
+ // In preview-only mode, position card below the clicked preview element
6159
+ positionCardBelowElement(previewElement);
6160
+ } else {
6161
+ // 常にソーステーブルの選択セル位置を基準にカードを配置
6162
+ positionCardForSelection(startRow, endRow, startCol, endCol);
6163
+ }
6140
6164
  commentInput.focus();
6141
6165
  }
6142
6166
 
@@ -6177,6 +6201,61 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6177
6201
  card.style.top = top + 'px';
6178
6202
  }
6179
6203
 
6204
+ // Position card below a clicked element (used in preview-only / narrow mode)
6205
+ // Falls back to above the element if below doesn't fit
6206
+ function positionCardBelowElement(element) {
6207
+ const cardWidth = card.offsetWidth || 380;
6208
+ const cardHeight = card.offsetHeight || 220;
6209
+ const margin = 12;
6210
+ const vw = window.innerWidth;
6211
+ const vh = window.innerHeight;
6212
+
6213
+ const mdLeft = document.querySelector('.md-left');
6214
+ let rect = element.getBoundingClientRect();
6215
+
6216
+ // Try below first: scroll to make room if needed
6217
+ const spaceBelow = vh - rect.bottom - margin;
6218
+ if (spaceBelow < cardHeight && mdLeft) {
6219
+ const scrollNeeded = cardHeight - spaceBelow + margin;
6220
+ const scrollBefore = mdLeft.scrollTop;
6221
+ mdLeft.scrollBy({ top: scrollNeeded, behavior: 'instant' });
6222
+ // Recalculate after scroll
6223
+ rect = element.getBoundingClientRect();
6224
+ }
6225
+
6226
+ // Horizontal: ensure card fits
6227
+ let left = rect.left;
6228
+ if (left + cardWidth > vw - margin) {
6229
+ left = vw - cardWidth - margin;
6230
+ }
6231
+ left = Math.max(margin, left);
6232
+
6233
+ // Vertical: prefer below, fallback to above
6234
+ const spaceBelowAfterScroll = vh - rect.bottom - margin;
6235
+ const spaceAbove = rect.top - margin;
6236
+ let top;
6237
+
6238
+ if (spaceBelowAfterScroll >= cardHeight) {
6239
+ // Fits below
6240
+ top = rect.bottom + margin;
6241
+ } else if (spaceAbove >= cardHeight) {
6242
+ // Doesn't fit below, but fits above
6243
+ top = rect.top - cardHeight - margin;
6244
+ } else {
6245
+ // Doesn't fit either way: choose the side with more space
6246
+ if (spaceAbove > spaceBelowAfterScroll) {
6247
+ top = Math.max(margin, rect.top - cardHeight - margin);
6248
+ } else {
6249
+ top = rect.bottom + margin;
6250
+ top = Math.min(top, vh - cardHeight - margin);
6251
+ }
6252
+ }
6253
+
6254
+ top = Math.max(margin, top);
6255
+ card.style.left = left + 'px';
6256
+ card.style.top = top + 'px';
6257
+ }
6258
+
6180
6259
  function positionCardForSelection(startRow, endRow, startCol, endCol) {
6181
6260
  const cardWidth = card.offsetWidth || 380;
6182
6261
  const cardHeight = card.offsetHeight || 220;
@@ -6255,6 +6334,8 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6255
6334
  card.style.display = 'none';
6256
6335
  currentKey = null;
6257
6336
  clearSelection();
6337
+ // Clear preview highlight
6338
+ document.querySelectorAll('.preview-highlight').forEach(el => el.classList.remove('preview-highlight'));
6258
6339
  // Re-enable scroll sync when card is closed
6259
6340
  window._disableScrollSync = false;
6260
6341
  }
@@ -7145,6 +7226,52 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7145
7226
  }
7146
7227
  }
7147
7228
 
7229
+ // --- Panel State (Preview-Only Toggle) ---
7230
+ const PANEL_STATE_KEY = 'reviw-panel-state';
7231
+ (function initPanelState() {
7232
+ const viewToggle = document.getElementById('view-toggle');
7233
+ const mdLayout = document.querySelector('.md-layout');
7234
+ if (!viewToggle || !mdLayout) return;
7235
+
7236
+ function applyPanelState(isPreviewOnly) {
7237
+ if (isPreviewOnly) {
7238
+ mdLayout.classList.add('preview-only');
7239
+ viewToggle.textContent = '\u{1F441}';
7240
+ viewToggle.title = 'Show source panel';
7241
+ viewToggle.classList.add('active');
7242
+ } else {
7243
+ mdLayout.classList.remove('preview-only');
7244
+ viewToggle.textContent = '\u{1F4DD}';
7245
+ viewToggle.title = 'Hide source panel';
7246
+ viewToggle.classList.remove('active');
7247
+ }
7248
+ }
7249
+
7250
+ // Load saved state
7251
+ const saved = localStorage.getItem(PANEL_STATE_KEY);
7252
+ if (saved === 'preview-only') {
7253
+ applyPanelState(true);
7254
+ }
7255
+
7256
+ // Also apply preview-only for narrow viewports (auto-detect)
7257
+ function checkNarrowMode() {
7258
+ if (window.innerWidth <= 960) {
7259
+ // In narrow mode, always act as preview-only (source is hidden by CSS)
7260
+ viewToggle.style.display = 'none';
7261
+ } else {
7262
+ viewToggle.style.display = '';
7263
+ }
7264
+ }
7265
+ checkNarrowMode();
7266
+ window.addEventListener('resize', checkNarrowMode);
7267
+
7268
+ viewToggle.addEventListener('click', () => {
7269
+ const isNowPreviewOnly = !mdLayout.classList.contains('preview-only');
7270
+ applyPanelState(isNowPreviewOnly);
7271
+ localStorage.setItem(PANEL_STATE_KEY, isNowPreviewOnly ? 'preview-only' : 'both');
7272
+ });
7273
+ })();
7274
+
7148
7275
  // --- Mermaid Initialization ---
7149
7276
  (function initMermaid() {
7150
7277
  if (typeof mermaid === 'undefined') return;
@@ -8867,13 +8994,43 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
8867
8994
  }
8868
8995
 
8869
8996
  // Helper: find matching source line for table cell (prioritizes table rows)
8870
- function findTableSourceLine(text, startFromLine) {
8997
+ function findTableSourceLine(text, startFromLine, element = null) {
8871
8998
  if (!text) return -1;
8872
8999
  startFromLine = startFromLine || 0;
8873
9000
  // Remove toggle icon characters (▼, ▶) that may be included from heading toggles
8874
9001
  const cleanText = text.replace(/[▼▶]/g, '').trim();
8875
9002
  const normalized = cleanText.replace(/\\s+/g, ' ').slice(0, 100);
8876
9003
  if (!normalized) return -1;
9004
+ const normalizedLower = normalized.toLowerCase();
9005
+
9006
+ function escapeRegExp(s) {
9007
+ return s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
9008
+ }
9009
+
9010
+ function normalizeCellText(cellText) {
9011
+ return stripMarkdown(cellText)
9012
+ .replace(/\\s+/g, ' ')
9013
+ .trim()
9014
+ .slice(0, 100);
9015
+ }
9016
+
9017
+ // If this click comes from a linked table cell, prefer matching by href for stable mapping.
9018
+ if (element) {
9019
+ const linkEl = element.querySelector('a[href]') || (element.matches?.('a[href]') ? element : null);
9020
+ const href = linkEl ? linkEl.getAttribute('href') : '';
9021
+ if (href) {
9022
+ var hrefSearchPasses = startFromLine > 0 ? [startFromLine, 0] : [0];
9023
+ for (var hrefPass = 0; hrefPass < hrefSearchPasses.length; hrefPass++) {
9024
+ var hrefSearchStart = hrefSearchPasses[hrefPass];
9025
+ var hrefSearchEnd = hrefPass === 0 && startFromLine > 0 ? DATA.length : (startFromLine > 0 ? startFromLine : DATA.length);
9026
+ for (let i = hrefSearchStart; i < hrefSearchEnd; i++) {
9027
+ const lineText = (DATA[i][0] || '').trim();
9028
+ if (!lineText || !lineText.startsWith('|')) continue;
9029
+ if (lineText.includes(href)) return i + 1;
9030
+ }
9031
+ }
9032
+ }
9033
+ }
8877
9034
 
8878
9035
  // Two-pass strategy: search from startFromLine first, then fallback to 0
8879
9036
  var searchPasses = startFromLine > 0 ? [startFromLine, 0] : [0];
@@ -8886,27 +9043,37 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
8886
9043
  const lineText = (DATA[i][0] || '').trim();
8887
9044
  if (!lineText || !lineText.startsWith('|')) continue;
8888
9045
 
8889
- // Split into cells and check for exact match (excluding markdown syntax)
9046
+ // Split into cells and check for exact match, including markdown link display text.
8890
9047
  const cells = lineText.split('|').map(c => c.trim());
8891
9048
  for (const cell of cells) {
8892
- // Skip cells that are markdown images/links (start with ![, contain []())
8893
- if (cell.match(/^!?\\[.*\\]\\(.*\\)$/)) continue;
9049
+ if (!cell) continue;
9050
+ const plainCell = normalizeCellText(cell);
8894
9051
 
8895
- // Check for exact cell text match
9052
+ // Exact raw-cell or rendered-cell text match.
8896
9053
  if (cell === normalized) return i + 1;
9054
+ if (plainCell === normalized) return i + 1;
9055
+ if (plainCell.toLowerCase() === normalizedLower) return i + 1;
8897
9056
 
8898
- // For short text (like header cells), require exact word match
8899
- if (normalized.length <= 5 && cell === normalized) return i + 1;
9057
+ // For short labels (e.g. Go/OK), allow whole-word match in rendered cell.
9058
+ if (normalized.length <= 5 && plainCell) {
9059
+ const wordPattern = new RegExp('(^|\\s)' + escapeRegExp(normalizedLower) + '(\\s|$)', 'i');
9060
+ if (wordPattern.test(plainCell.toLowerCase())) return i + 1;
9061
+ }
8900
9062
  }
8901
9063
  }
8902
9064
 
8903
- // Second pass: look for partial matches (including inside markdown syntax)
9065
+ // Second pass: partial rendered-cell matching.
8904
9066
  for (let i = searchStart; i < searchEnd; i++) {
8905
9067
  const lineText = (DATA[i][0] || '').trim();
8906
9068
  if (!lineText || !lineText.startsWith('|')) continue;
8907
9069
 
8908
- const lineNorm = lineText.replace(/\\s+/g, ' ').slice(0, 100);
8909
- if (lineNorm.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
9070
+ const cells = lineText.split('|').map(c => normalizeCellText(c));
9071
+ for (const plainCell of cells) {
9072
+ if (!plainCell) continue;
9073
+ const plainLower = plainCell.toLowerCase();
9074
+ if (normalized.length > 5 && plainLower.includes(normalizedLower.slice(0, 30))) return i + 1;
9075
+ if (normalized.length > 5 && normalizedLower.includes(plainLower.slice(0, 30)) && plainLower.length > 5) return i + 1;
9076
+ }
8910
9077
  }
8911
9078
  }
8912
9079
 
@@ -9077,6 +9244,12 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
9077
9244
  selection = { startRow, endRow: endRow || startRow, startCol: 1, endCol: 1 };
9078
9245
  updateSelectionVisual();
9079
9246
 
9247
+ // Highlight the clicked preview element
9248
+ document.querySelectorAll('.preview-highlight').forEach(el => el.classList.remove('preview-highlight'));
9249
+ if (clickedPreviewElement) {
9250
+ clickedPreviewElement.classList.add('preview-highlight');
9251
+ }
9252
+
9080
9253
  // Clear header selection
9081
9254
  document.querySelectorAll('thead th.selected').forEach(el => el.classList.remove('selected'));
9082
9255
 
@@ -9097,7 +9270,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
9097
9270
  }
9098
9271
 
9099
9272
  // Open the card (synchronously) - now target cell should be visible for positioning
9100
- openCardForSelection();
9273
+ openCardForSelection(clickedPreviewElement);
9101
9274
 
9102
9275
  // Re-add scroll handlers after a delay to allow scroll to settle
9103
9276
  setTimeout(() => {
@@ -9172,7 +9345,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
9172
9345
  const isTableCell = parentBlock.tagName === 'TD' || parentBlock.tagName === 'TH';
9173
9346
  const closestH = findClosestHeadingAbove(parentBlock);
9174
9347
  const hLine = getHeadingSourceLine(closestH);
9175
- const line = isTableCell ? findTableSourceLine(parentBlock.textContent, hLine) : findSourceLine(parentBlock.textContent, null, hLine);
9348
+ const line = isTableCell ? findTableSourceLine(parentBlock.textContent, hLine, parentBlock) : findSourceLine(parentBlock.textContent, null, hLine);
9176
9349
  if (line > 0) {
9177
9350
  selectSourceRange(line, null, parentBlock);
9178
9351
  }
@@ -9233,7 +9406,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
9233
9406
 
9234
9407
  // Use table-specific search for table cells, otherwise use element-aware search
9235
9408
  const isTableCell = target.tagName === 'TD' || target.tagName === 'TH';
9236
- const line = isTableCell ? findTableSourceLine(searchText, headingLine) : findSourceLine(searchText, target, headingLine);
9409
+ const line = isTableCell ? findTableSourceLine(searchText, headingLine, target) : findSourceLine(searchText, target, headingLine);
9237
9410
  if (line <= 0) return;
9238
9411
 
9239
9412
  // Don't prevent default for summary elements - let native <details> toggle work
@@ -9702,6 +9875,7 @@ function checkExistingServer(filePath) {
9702
9875
 
9703
9876
  // --- History File Management ---
9704
9877
  const HISTORY_DIR = path.join(os.homedir(), '.reviw', 'history');
9878
+ const OUTPUT_DIR = path.join(os.homedir(), '.reviw', 'outputs');
9705
9879
  const HISTORY_MAX = 50;
9706
9880
 
9707
9881
  function getHistoryFilePath(filePath) {
@@ -9720,6 +9894,16 @@ function ensureHistoryDir() {
9720
9894
  }
9721
9895
  }
9722
9896
 
9897
+ function ensureOutputDir() {
9898
+ try {
9899
+ if (!fs.existsSync(OUTPUT_DIR)) {
9900
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 });
9901
+ }
9902
+ } catch (err) {
9903
+ // Ignore errors
9904
+ }
9905
+ }
9906
+
9723
9907
  function loadHistoryFromFile(filePath) {
9724
9908
  try {
9725
9909
  const historyPath = getHistoryFilePath(filePath);
@@ -9748,6 +9932,16 @@ function saveHistoryToFile(filePath, historyEntry) {
9748
9932
  }
9749
9933
  }
9750
9934
 
9935
+ function shouldSaveHistory(payload) {
9936
+ if (!payload || typeof payload !== "object") return false;
9937
+ if (Array.isArray(payload.comments) && payload.comments.length > 0) return true;
9938
+ if (typeof payload.summary === "string" && payload.summary.trim().length > 0) return true;
9939
+ if (Array.isArray(payload.reviwAnswers) && payload.reviwAnswers.length > 0) return true;
9940
+ if (Array.isArray(payload.summaryImages) && payload.summaryImages.length > 0) return true;
9941
+ if (Array.isArray(payload.prompts) && payload.prompts.length > 0) return true;
9942
+ return false;
9943
+ }
9944
+
9751
9945
  // Try to activate an existing browser tab with the given URL (macOS only)
9752
9946
  // Returns true if a tab was activated, false otherwise
9753
9947
  function tryActivateExistingTab(url) {
@@ -9874,14 +10068,14 @@ function openBrowser(url, delay = 0) {
9874
10068
  }
9875
10069
 
9876
10070
  function outputAllResults() {
9877
- console.log("/do");
10071
+ const outLines = ["/do"];
9878
10072
  if (allResults.length === 1) {
9879
10073
  const yamlOut = yaml.dump(allResults[0], { noRefs: true, lineWidth: 120 });
9880
- console.log(yamlOut.trim());
10074
+ outLines.push(yamlOut.trim());
9881
10075
  } else {
9882
10076
  const combined = { files: allResults };
9883
10077
  const yamlOut = yaml.dump(combined, { noRefs: true, lineWidth: 120 });
9884
- console.log(yamlOut.trim());
10078
+ outLines.push(yamlOut.trim());
9885
10079
  }
9886
10080
 
9887
10081
  // Output answered questions if any
@@ -9892,10 +10086,27 @@ function outputAllResults() {
9892
10086
  }
9893
10087
  }
9894
10088
  if (allAnswers.length > 0) {
9895
- console.log("\n[REVIW_ANSWERS]");
10089
+ outLines.push("");
10090
+ outLines.push("[REVIW_ANSWERS]");
9896
10091
  const answersYaml = yaml.dump(allAnswers, { noRefs: true, lineWidth: 120 });
9897
- console.log(answersYaml.trim());
9898
- console.log("[/REVIW_ANSWERS]");
10092
+ outLines.push(answersYaml.trim());
10093
+ outLines.push("[/REVIW_ANSWERS]");
10094
+ }
10095
+
10096
+ const outputText = outLines.join("\n");
10097
+ console.log(outputText);
10098
+
10099
+ // Durable fallback: persist final output to file for recovery.
10100
+ try {
10101
+ ensureOutputDir();
10102
+ const latestPath = path.join(OUTPUT_DIR, "latest.yaml");
10103
+ fs.writeFileSync(latestPath, outputText + "\n", { mode: 0o600 });
10104
+
10105
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
10106
+ const archivePath = path.join(OUTPUT_DIR, `output-${ts}.yaml`);
10107
+ fs.writeFileSync(archivePath, outputText + "\n", { mode: 0o600 });
10108
+ } catch (err) {
10109
+ // Keep stdout behavior even if file backup fails.
9899
10110
  }
9900
10111
  }
9901
10112
 
@@ -10041,8 +10252,8 @@ function createFileServer(filePath, fileIndex = 0) {
10041
10252
  if (payload) {
10042
10253
  payload = processPayloadImages(payload, ctx.baseDir);
10043
10254
  }
10044
- // Save to file-based history (only if there are comments)
10045
- if (payload && (payload.comments?.length > 0 || payload.submitComment)) {
10255
+ // Save to file-based history when review includes any meaningful submission.
10256
+ if (shouldSaveHistory(payload)) {
10046
10257
  saveHistoryToFile(ctx.filePath, payload);
10047
10258
  }
10048
10259
  res.writeHead(200, { "Content-Type": "text/plain" });
@@ -10397,9 +10608,9 @@ function createDiffServer(diffContent) {
10397
10608
  if (payload) {
10398
10609
  payload = processPayloadImages(payload, process.cwd());
10399
10610
  }
10400
- // Save to file-based history (only if there are comments)
10401
- // For diff mode, use relativePath as identifier
10402
- if (payload && (payload.comments?.length > 0 || payload.submitComment)) {
10611
+ // Save to file-based history when review includes any meaningful submission.
10612
+ // For diff mode, use relativePath as identifier.
10613
+ if (shouldSaveHistory(payload)) {
10403
10614
  const filePath = ctx.diffData?.relativePath || 'stdin-diff';
10404
10615
  saveHistoryToFile(filePath, payload);
10405
10616
  }
@@ -10516,7 +10727,7 @@ if (require.main === module) {
10516
10727
  console.log("Starting diff viewer from stdin...");
10517
10728
  serversRunning = 1;
10518
10729
  await createDiffServer(stdinContent);
10519
- console.log("Close the browser tab or Submit & Exit to finish.");
10730
+ console.log('Click "Submit & Exit" to finish.');
10520
10731
  } else {
10521
10732
  // Treat as plain text
10522
10733
  console.log("Starting text viewer from stdin...");
@@ -10553,7 +10764,7 @@ if (require.main === module) {
10553
10764
  for (let i = 0; i < filesToStart.length; i++) {
10554
10765
  await createFileServer(filesToStart[i], i);
10555
10766
  }
10556
- console.log("Close all browser tabs or Submit & Exit to finish.");
10767
+ console.log('Click "Submit & Exit" in each opened viewer to finish.');
10557
10768
  } else {
10558
10769
  // No files and no stdin: try auto git diff
10559
10770
  console.log(`reviw v${VERSION}`);
@@ -10575,7 +10786,7 @@ if (require.main === module) {
10575
10786
  console.log("Starting diff viewer...");
10576
10787
  serversRunning = 1;
10577
10788
  await createDiffServer(gitDiff);
10578
- console.log("Close the browser tab or Submit & Exit to finish.");
10789
+ console.log('Click "Submit & Exit" to finish.');
10579
10790
  } catch (err) {
10580
10791
  console.error(err.message);
10581
10792
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {