difit 3.1.18 → 4.0.1

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 (100) hide show
  1. package/README.ja.md +44 -16
  2. package/README.ko.md +44 -16
  3. package/README.md +44 -16
  4. package/README.zh.md +44 -16
  5. package/dist/cli/github.d.ts +65 -0
  6. package/dist/cli/github.js +296 -0
  7. package/dist/cli/github.test.d.ts +1 -0
  8. package/dist/cli/github.test.js +341 -0
  9. package/dist/cli/index.js +42 -1
  10. package/dist/cli/index.test.js +330 -4
  11. package/dist/cli/utils.d.ts +2 -8
  12. package/dist/cli/utils.js +4 -43
  13. package/dist/cli/utils.test.js +50 -67
  14. package/dist/client/assets/{_basePickBy-DyiQWUmK.js → _basePickBy-ChXFkTMC.js} +1 -1
  15. package/dist/client/assets/{_baseUniq-DivSZEOF.js → _baseUniq-Mj_sFFQW.js} +1 -1
  16. package/dist/client/assets/{arc-c0kacVOL.js → arc-BMA6S9F1.js} +1 -1
  17. package/dist/client/assets/{architectureDiagram-2XIMDMQ5-ubymLNEe.js → architectureDiagram-2XIMDMQ5-0uiM_v5K.js} +1 -1
  18. package/dist/client/assets/{blockDiagram-WCTKOSBZ-F9D8w4_S.js → blockDiagram-WCTKOSBZ-CM7ZLL6F.js} +1 -1
  19. package/dist/client/assets/{c4Diagram-IC4MRINW-JE9Kx4yQ.js → c4Diagram-IC4MRINW-DKtCnVwn.js} +1 -1
  20. package/dist/client/assets/channel-D057yzDp.js +1 -0
  21. package/dist/client/assets/{chunk-4BX2VUAB-CYOCoDMc.js → chunk-4BX2VUAB-Wsl8DxEB.js} +1 -1
  22. package/dist/client/assets/{chunk-55IACEB6-PRBuiJg9.js → chunk-55IACEB6-CHm9X5i7.js} +1 -1
  23. package/dist/client/assets/{chunk-FMBD7UC4-C0eJ7JsI.js → chunk-FMBD7UC4-BSa8SHgd.js} +1 -1
  24. package/dist/client/assets/{chunk-JSJVCQXG-QZotPSqo.js → chunk-JSJVCQXG-Cpk76oJ3.js} +1 -1
  25. package/dist/client/assets/{chunk-KX2RTZJC-B8du3tt8.js → chunk-KX2RTZJC-D8YvfZVu.js} +1 -1
  26. package/dist/client/assets/{chunk-NQ4KR5QH-B10ldi5m.js → chunk-NQ4KR5QH-BogviJOv.js} +1 -1
  27. package/dist/client/assets/{chunk-QZHKN3VN-CpwW9rUQ.js → chunk-QZHKN3VN-DwLJYu26.js} +1 -1
  28. package/dist/client/assets/{chunk-WL4C6EOR-DwKPHpbL.js → chunk-WL4C6EOR-BFDpGxW2.js} +1 -1
  29. package/dist/client/assets/classDiagram-VBA2DB6C---D4iOts.js +1 -0
  30. package/dist/client/assets/classDiagram-v2-RAHNMMFH---D4iOts.js +1 -0
  31. package/dist/client/assets/clone-xSR3otEf.js +1 -0
  32. package/dist/client/assets/{cose-bilkent-S5V4N54A-p76yal75.js → cose-bilkent-S5V4N54A-oEosZ_5y.js} +1 -1
  33. package/dist/client/assets/{dagre-KLK3FWXG-CdDyed3V.js → dagre-KLK3FWXG-gFld4u1H.js} +1 -1
  34. package/dist/client/assets/{diagram-E7M64L7V-BaC8dXuW.js → diagram-E7M64L7V-gJq3kSrf.js} +1 -1
  35. package/dist/client/assets/{diagram-IFDJBPK2-BGf8xwJI.js → diagram-IFDJBPK2-BsUm_q22.js} +1 -1
  36. package/dist/client/assets/{diagram-P4PSJMXO-D3j16gBZ.js → diagram-P4PSJMXO-juB-sfcR.js} +1 -1
  37. package/dist/client/assets/{erDiagram-INFDFZHY-DFpDdocf.js → erDiagram-INFDFZHY-Dn77qXAt.js} +1 -1
  38. package/dist/client/assets/{flowDiagram-PKNHOUZH-Cz4mb4IF.js → flowDiagram-PKNHOUZH-DtmvDYdN.js} +1 -1
  39. package/dist/client/assets/{ganttDiagram-A5KZAMGK-CNzY9ua5.js → ganttDiagram-A5KZAMGK-BlDaKLbQ.js} +1 -1
  40. package/dist/client/assets/{gitGraphDiagram-K3NZZRJ6-DCSxL8EQ.js → gitGraphDiagram-K3NZZRJ6-DeAAeuMS.js} +1 -1
  41. package/dist/client/assets/{graph-BC2BV1-T.js → graph-NX9gBP47.js} +1 -1
  42. package/dist/client/assets/index-VxkpzDXr.css +1 -0
  43. package/dist/client/assets/index-kJdw4DY-.js +98 -0
  44. package/dist/client/assets/{infoDiagram-LFFYTUFH-BKSspZbH.js → infoDiagram-LFFYTUFH-CAaX023c.js} +1 -1
  45. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-DZ2IRYwc.js → ishikawaDiagram-PHBUUO56-CmiTQStv.js} +1 -1
  46. package/dist/client/assets/{journeyDiagram-4ABVD52K-BrjXAkii.js → journeyDiagram-4ABVD52K-B0SHC7mz.js} +1 -1
  47. package/dist/client/assets/{kanban-definition-K7BYSVSG-B1mfOekw.js → kanban-definition-K7BYSVSG-IfRdhzz7.js} +1 -1
  48. package/dist/client/assets/{layout-CWTG02uT.js → layout-l3OdNQhJ.js} +1 -1
  49. package/dist/client/assets/{linear-CGgOKp1d.js → linear-CQ0hx5Qs.js} +1 -1
  50. package/dist/client/assets/{mermaid.core-DTPtVBG7.js → mermaid.core-DqlPTabt.js} +4 -4
  51. package/dist/client/assets/{mindmap-definition-YRQLILUH-DByVRPFT.js → mindmap-definition-YRQLILUH-DIgSmG_f.js} +1 -1
  52. package/dist/client/assets/{pieDiagram-SKSYHLDU-DEgvAxAy.js → pieDiagram-SKSYHLDU-FzM5qoIB.js} +1 -1
  53. package/dist/client/assets/{prism-csharp-DqTrHqwJ.js → prism-csharp-DCfUUOUs.js} +1 -1
  54. package/dist/client/assets/{prism-elixir-DEJaM00V.js → prism-elixir-riuOL1mm.js} +1 -1
  55. package/dist/client/assets/{prism-hcl-HvJ0aPiH.js → prism-hcl-CizuX1s4.js} +1 -1
  56. package/dist/client/assets/{prism-java-DDUFERTh.js → prism-java-DYCKrDUh.js} +1 -1
  57. package/dist/client/assets/{prism-perl-CNA3SNC9.js → prism-perl-BJwBYR3Y.js} +1 -1
  58. package/dist/client/assets/{prism-php-hBQuhE2A.js → prism-php-BMhFuA7y.js} +1 -1
  59. package/dist/client/assets/{prism-ruby-BKap8imy.js → prism-ruby-Bcu0cDEh.js} +1 -1
  60. package/dist/client/assets/{prism-solidity-DHc7LZHq.js → prism-solidity-DDDs3w-w.js} +1 -1
  61. package/dist/client/assets/{quadrantDiagram-337W2JSQ-DTtikTvc.js → quadrantDiagram-337W2JSQ-BBrApyD7.js} +1 -1
  62. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-B34R-xD0.js → requirementDiagram-Z7DCOOCP-CLXiwUaA.js} +1 -1
  63. package/dist/client/assets/{sankeyDiagram-WA2Y5GQK-Dts1ZXRC.js → sankeyDiagram-WA2Y5GQK-9Y3Ly5qe.js} +1 -1
  64. package/dist/client/assets/{sequenceDiagram-2WXFIKYE-DzM3WhEY.js → sequenceDiagram-2WXFIKYE-DEpX1BA5.js} +1 -1
  65. package/dist/client/assets/{stateDiagram-RAJIS63D-B2dF8YnK.js → stateDiagram-RAJIS63D-Ck3ullwA.js} +1 -1
  66. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-X6UiDsar.js +1 -0
  67. package/dist/client/assets/{timeline-definition-YZTLITO2-BO4OtcEm.js → timeline-definition-YZTLITO2-CMezf3XV.js} +1 -1
  68. package/dist/client/assets/{treemap-KZPCXAKY-DaXnvVRH.js → treemap-KZPCXAKY-DqrcV0gQ.js} +1 -1
  69. package/dist/client/assets/{vennDiagram-LZ73GAT5-AIMhd8Js.js → vennDiagram-LZ73GAT5-eQg945Fz.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-JWTSCODW-Ch6W1f7P.js → xychartDiagram-JWTSCODW-_hqdXeX1.js} +1 -1
  71. package/dist/client/index.html +2 -2
  72. package/dist/server/generated-file-check.js +113 -58
  73. package/dist/server/generated-file-check.test.js +2 -0
  74. package/dist/server/git-diff-tui.d.ts +1 -1
  75. package/dist/server/git-diff-tui.js +7 -5
  76. package/dist/server/git-diff-tui.test.d.ts +1 -0
  77. package/dist/server/git-diff-tui.test.js +60 -0
  78. package/dist/server/git-diff.d.ts +4 -1
  79. package/dist/server/git-diff.js +73 -9
  80. package/dist/server/git-diff.test.js +46 -0
  81. package/dist/server/server.d.ts +3 -0
  82. package/dist/server/server.js +111 -37
  83. package/dist/server/server.test.js +152 -0
  84. package/dist/tui/App.d.ts +1 -0
  85. package/dist/tui/App.js +2 -2
  86. package/dist/types/diff.d.ts +74 -14
  87. package/dist/utils/commentFormatting.d.ts +4 -2
  88. package/dist/utils/commentFormatting.js +57 -19
  89. package/dist/utils/commentImports.d.ts +9 -0
  90. package/dist/utils/commentImports.js +264 -0
  91. package/dist/utils/commentImports.test.d.ts +1 -0
  92. package/dist/utils/commentImports.test.js +197 -0
  93. package/package.json +1 -1
  94. package/dist/client/assets/channel-Ca4c0q8d.js +0 -1
  95. package/dist/client/assets/classDiagram-VBA2DB6C-CJLw9sK7.js +0 -1
  96. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CJLw9sK7.js +0 -1
  97. package/dist/client/assets/clone-D0mDLEir.js +0 -1
  98. package/dist/client/assets/index-DHt9OwVU.css +0 -1
  99. package/dist/client/assets/index-mE8CA51x.js +0 -95
  100. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-ReD0hBzH.js +0 -1
package/dist/cli/index.js CHANGED
@@ -6,7 +6,8 @@ import pkg from '../../package.json' with { type: 'json' };
6
6
  import { startServer } from '../server/server.js';
7
7
  import { DiffMode } from '../types/watch.js';
8
8
  import { DEFAULT_DIFF_VIEW_MODE, normalizeDiffViewMode } from '../utils/diffMode.js';
9
- import { shouldReadStdin, findUntrackedFiles, markFilesIntentToAdd, promptUser, validateDiffArguments, getPrPatch, getGitRoot, } from './utils.js';
9
+ import { shouldReadStdin, findUntrackedFiles, markFilesIntentToAdd, promptUser, parseCommentOptions, validateDiffArguments, getGitRoot, } from './utils.js';
10
+ import { getPrPatch, getPrCommentImports } from './github.js';
10
11
  function isSpecialArg(arg) {
11
12
  return arg === 'working' || arg === 'staged' || arg === '.';
12
13
  }
@@ -39,15 +40,32 @@ program
39
40
  .option('--host <host>', 'host address to bind', '')
40
41
  .option('--no-open', 'do not automatically open browser')
41
42
  .option('--mode <mode>', 'diff mode (split or unified)', normalizeDiffViewMode, DEFAULT_DIFF_VIEW_MODE)
43
+ .option('--comment <json>', 'inject initial review comments (repeatable, accepts a JSON object or array)', (value, previous = []) => [...previous, value], [])
42
44
  .option('--tui', 'use terminal UI instead of web interface')
43
45
  .option('--pr <url>', 'GitHub PR URL to review (e.g., https://github.com/owner/repo/pull/123)')
44
46
  .option('--clean', 'start with a clean slate by clearing all existing comments')
45
47
  .option('--include-untracked', 'automatically include untracked files in diff')
46
48
  .option('--keep-alive', 'keep server running even after browser disconnects')
49
+ .option('--context <lines>', 'number of context lines shown around each change', parseInt)
47
50
  .action(async (commitish, compareWith, options) => {
48
51
  try {
49
52
  let stdinDiff;
50
53
  let stdinReviewLabel = 'diff from stdin';
54
+ let manualCommentImports = [];
55
+ let commentImports = [];
56
+ if (options.context !== undefined &&
57
+ (!Number.isInteger(options.context) || options.context < 0)) {
58
+ console.error('Error: --context must be a non-negative integer');
59
+ process.exit(1);
60
+ }
61
+ try {
62
+ manualCommentImports = parseCommentOptions(options.comment);
63
+ commentImports = manualCommentImports;
64
+ }
65
+ catch (error) {
66
+ console.error(`Error: ${error instanceof Error ? error.message : 'Invalid --comment value'}`);
67
+ process.exit(1);
68
+ }
51
69
  if (options.pr) {
52
70
  if (commitish !== 'HEAD' || compareWith) {
53
71
  console.error('Error: --pr option cannot be used with positional arguments');
@@ -57,6 +75,10 @@ program
57
75
  console.error('Error: --pr option cannot be used with --tui');
58
76
  process.exit(1);
59
77
  }
78
+ if (options.context !== undefined) {
79
+ console.error('Error: --context option cannot be used with --pr');
80
+ process.exit(1);
81
+ }
60
82
  try {
61
83
  stdinDiff = getPrPatch(options.pr);
62
84
  stdinReviewLabel = options.pr;
@@ -65,6 +87,13 @@ program
65
87
  console.error(`Error resolving PR: ${error instanceof Error ? error.message : 'Unknown error'}`);
66
88
  process.exit(1);
67
89
  }
90
+ try {
91
+ const prCommentImports = await getPrCommentImports(options.pr);
92
+ commentImports = [...prCommentImports, ...manualCommentImports];
93
+ }
94
+ catch (error) {
95
+ console.warn(`Warning: Failed to load PR review comments: ${error instanceof Error ? error.message : 'Unknown error'}`);
96
+ }
68
97
  }
69
98
  else {
70
99
  // Check if we should read from stdin
@@ -75,6 +104,10 @@ program
75
104
  hasTuiOption: Boolean(options.tui),
76
105
  });
77
106
  if (readFromStdin) {
107
+ if (options.context !== undefined) {
108
+ console.error('Error: --context option cannot be used with stdin diff');
109
+ process.exit(1);
110
+ }
78
111
  // Read unified diff from stdin
79
112
  stdinDiff = await readStdin();
80
113
  if (!stdinDiff.trim()) {
@@ -93,6 +126,7 @@ program
93
126
  mode: options.mode,
94
127
  clearComments: options.clean,
95
128
  keepAlive: options.keepAlive,
129
+ ...(commentImports.length > 0 ? { commentImports } : {}),
96
130
  });
97
131
  console.log(`\n🚀 difit server started on ${url}`);
98
132
  console.log(`📋 Reviewing: ${stdinReviewLabel}`);
@@ -136,6 +170,10 @@ program
136
170
  await handleUntrackedFiles(git, options.includeUntracked);
137
171
  }
138
172
  if (options.tui) {
173
+ if (commentImports.length > 0) {
174
+ console.error('Error: --comment option cannot be used with --tui');
175
+ process.exit(1);
176
+ }
139
177
  // Check if we're in a TTY environment
140
178
  if (!process.stdin.isTTY) {
141
179
  console.error('Error: TUI mode requires an interactive terminal (TTY).');
@@ -150,6 +188,7 @@ program
150
188
  baseCommitish,
151
189
  mode: options.mode,
152
190
  repoPath,
191
+ contextLines: options.context,
153
192
  }));
154
193
  return;
155
194
  }
@@ -167,8 +206,10 @@ program
167
206
  mode: options.mode,
168
207
  clearComments: options.clean,
169
208
  keepAlive: options.keepAlive,
209
+ contextLines: options.context,
170
210
  diffMode: determineDiffMode(targetCommitish, compareWith),
171
211
  repoPath,
212
+ ...(commentImports.length > 0 ? { commentImports } : {}),
172
213
  });
173
214
  console.log(`\n🚀 difit server started on ${url}`);
174
215
  console.log(`📋 Reviewing: ${targetCommitish}`);
@@ -14,12 +14,16 @@ vi.mock('./utils.js', async () => {
14
14
  promptUser: vi.fn(),
15
15
  findUntrackedFiles: vi.fn(),
16
16
  markFilesIntentToAdd: vi.fn(),
17
- getPrPatch: vi.fn(),
18
17
  };
19
18
  });
19
+ vi.mock('./github.js', () => ({
20
+ getPrPatch: vi.fn(),
21
+ getPrCommentImports: vi.fn(),
22
+ }));
20
23
  const { simpleGit } = await import('simple-git');
21
24
  const { startServer } = await import('../server/server.js');
22
- const { promptUser, findUntrackedFiles, markFilesIntentToAdd, getPrPatch } = await import('./utils.js');
25
+ const { promptUser, findUntrackedFiles, markFilesIntentToAdd, parseCommentOptions, shouldReadStdin, } = await import('./utils.js');
26
+ const { getPrPatch, getPrCommentImports } = await import('./github.js');
23
27
  describe('CLI index.ts', () => {
24
28
  let mockGit;
25
29
  let mockStartServer;
@@ -27,9 +31,12 @@ describe('CLI index.ts', () => {
27
31
  let mockFindUntrackedFiles;
28
32
  let mockMarkFilesIntentToAdd;
29
33
  let mockGetPrPatch;
34
+ let mockGetPrCommentImports;
35
+ let actualParseCommentOptions;
30
36
  // Store original console methods
31
37
  let originalConsoleLog;
32
38
  let originalConsoleError;
39
+ let originalConsoleWarn;
33
40
  let originalProcessExit;
34
41
  beforeEach(() => {
35
42
  // Setup mocks
@@ -48,12 +55,17 @@ describe('CLI index.ts', () => {
48
55
  mockFindUntrackedFiles = vi.mocked(findUntrackedFiles);
49
56
  mockMarkFilesIntentToAdd = vi.mocked(markFilesIntentToAdd);
50
57
  mockGetPrPatch = vi.mocked(getPrPatch);
58
+ mockGetPrCommentImports = vi.mocked(getPrCommentImports);
59
+ mockGetPrCommentImports.mockResolvedValue([]);
60
+ actualParseCommentOptions = parseCommentOptions;
51
61
  // Mock console and process.exit
52
62
  originalConsoleLog = console.log;
53
63
  originalConsoleError = console.error;
64
+ originalConsoleWarn = console.warn;
54
65
  originalProcessExit = process.exit;
55
66
  console.log = vi.fn();
56
67
  console.error = vi.fn();
68
+ console.warn = vi.fn();
57
69
  process.exit = vi.fn();
58
70
  // Reset all mocks
59
71
  vi.clearAllMocks();
@@ -62,6 +74,7 @@ describe('CLI index.ts', () => {
62
74
  // Restore original methods
63
75
  console.log = originalConsoleLog;
64
76
  console.error = originalConsoleError;
77
+ console.warn = originalConsoleWarn;
65
78
  process.exit = originalProcessExit;
66
79
  });
67
80
  describe('CLI argument processing', () => {
@@ -200,6 +213,11 @@ describe('CLI index.ts', () => {
200
213
  args: ['--keep-alive'],
201
214
  expectedOptions: { keepAlive: true },
202
215
  },
216
+ {
217
+ name: '--context option',
218
+ args: ['--context', '5'],
219
+ expectedOptions: { context: 5 },
220
+ },
203
221
  ])('$name', async ({ args, expectedOptions }) => {
204
222
  mockFindUntrackedFiles.mockResolvedValue([]);
205
223
  const program = new Command();
@@ -214,6 +232,7 @@ describe('CLI index.ts', () => {
214
232
  .option('--pr <url>', 'pr')
215
233
  .option('--clean', 'start with a clean slate by clearing all existing comments')
216
234
  .option('--keep-alive', 'keep server running even after browser disconnects')
235
+ .option('--context <lines>', 'context', parseInt)
217
236
  .action(async (commitish, _compareWith, options) => {
218
237
  let targetCommitish = commitish;
219
238
  let baseCommitish = commitish + '^';
@@ -226,6 +245,7 @@ describe('CLI index.ts', () => {
226
245
  mode: options.mode,
227
246
  clearComments: options.clean,
228
247
  keepAlive: options.keepAlive,
248
+ contextLines: options.context,
229
249
  });
230
250
  });
231
251
  await program.parseAsync([...args], { from: 'user' });
@@ -238,10 +258,91 @@ describe('CLI index.ts', () => {
238
258
  mode: expectedOptions.mode || 'split',
239
259
  clearComments: expectedOptions.clean,
240
260
  keepAlive: expectedOptions.keepAlive,
261
+ contextLines: expectedOptions.context,
241
262
  };
242
263
  expect(mockStartServer).toHaveBeenCalledWith(expectedCall);
243
264
  });
244
265
  });
266
+ describe('--context option', () => {
267
+ it('rejects negative values', async () => {
268
+ const program = new Command();
269
+ program
270
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
271
+ .argument('[compare-with]', 'compare-with')
272
+ .option('--context <lines>', 'context', parseInt)
273
+ .action(async (commitish, _compareWith, options) => {
274
+ if (options.context !== undefined &&
275
+ (!Number.isInteger(options.context) || options.context < 0)) {
276
+ console.error('Error: --context must be a non-negative integer');
277
+ process.exit(1);
278
+ return;
279
+ }
280
+ await startServer({
281
+ targetCommitish: commitish,
282
+ baseCommitish: `${commitish}^`,
283
+ contextLines: options.context,
284
+ });
285
+ });
286
+ await program.parseAsync(['--context', '-1'], { from: 'user' });
287
+ expect(console.error).toHaveBeenCalledWith('Error: --context must be a non-negative integer');
288
+ expect(process.exit).toHaveBeenCalledWith(1);
289
+ expect(mockStartServer).not.toHaveBeenCalled();
290
+ });
291
+ it('rejects --context with --pr', async () => {
292
+ const prUrl = 'https://github.com/owner/repo/pull/123';
293
+ const program = new Command();
294
+ program
295
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
296
+ .argument('[compare-with]', 'compare-with')
297
+ .option('--context <lines>', 'context', parseInt)
298
+ .option('--pr <url>', 'pr')
299
+ .action(async (_commitish, _compareWith, options) => {
300
+ if (options.pr && options.context !== undefined) {
301
+ console.error('Error: --context option cannot be used with --pr');
302
+ process.exit(1);
303
+ return;
304
+ }
305
+ await startServer({
306
+ stdinDiff: getPrPatch(options.pr),
307
+ contextLines: options.context,
308
+ });
309
+ });
310
+ await program.parseAsync(['--pr', prUrl, '--context', '3'], { from: 'user' });
311
+ expect(console.error).toHaveBeenCalledWith('Error: --context option cannot be used with --pr');
312
+ expect(process.exit).toHaveBeenCalledWith(1);
313
+ expect(mockGetPrPatch).not.toHaveBeenCalled();
314
+ expect(mockStartServer).not.toHaveBeenCalled();
315
+ });
316
+ it('rejects --context with stdin diff', async () => {
317
+ const program = new Command();
318
+ program
319
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
320
+ .argument('[compare-with]', 'compare-with')
321
+ .option('--context <lines>', 'context', parseInt)
322
+ .option('--tui', 'tui')
323
+ .action(async (commitish, _compareWith, options) => {
324
+ const readFromStdin = shouldReadStdin({
325
+ commitish,
326
+ hasPositionalArgs: program.args.length > 0,
327
+ hasPrOption: false,
328
+ hasTuiOption: Boolean(options.tui),
329
+ });
330
+ if (readFromStdin && options.context !== undefined) {
331
+ console.error('Error: --context option cannot be used with stdin diff');
332
+ process.exit(1);
333
+ return;
334
+ }
335
+ await startServer({
336
+ stdinDiff: 'diff --git a/file.ts b/file.ts',
337
+ contextLines: options.context,
338
+ });
339
+ });
340
+ await program.parseAsync(['-', '--context', '3'], { from: 'user' });
341
+ expect(console.error).toHaveBeenCalledWith('Error: --context option cannot be used with stdin diff');
342
+ expect(process.exit).toHaveBeenCalledWith(1);
343
+ expect(mockStartServer).not.toHaveBeenCalled();
344
+ });
345
+ });
245
346
  describe('Version option', () => {
246
347
  it('supports --version flag', async () => {
247
348
  const program = new Command();
@@ -409,14 +510,38 @@ describe('CLI index.ts', () => {
409
510
  });
410
511
  });
411
512
  describe('GitHub PR integration', () => {
412
- it('loads PR patch with gh and starts server with stdin diff', async () => {
513
+ it('loads PR patch, appends manual comments after PR imports, and starts server with stdin diff', async () => {
413
514
  const prUrl = 'https://github.com/owner/repo/pull/123';
414
515
  const prPatch = 'diff --git a/file.ts b/file.ts\nindex 1111111..2222222 100644\n';
516
+ const prCommentImports = [
517
+ {
518
+ type: 'thread',
519
+ id: 'PR_COMMENT_1',
520
+ filePath: 'src/example.ts',
521
+ position: { side: 'new', line: 10 },
522
+ body: 'Imported PR thread',
523
+ author: 'octocat',
524
+ createdAt: '2026-03-25T09:00:00Z',
525
+ updatedAt: '2026-03-25T09:05:00Z',
526
+ },
527
+ {
528
+ type: 'reply',
529
+ id: 'PR_COMMENT_2',
530
+ filePath: 'src/example.ts',
531
+ position: { side: 'new', line: 10 },
532
+ body: 'Imported PR reply',
533
+ author: 'hubot',
534
+ createdAt: '2026-03-25T09:10:00Z',
535
+ updatedAt: '2026-03-25T09:12:00Z',
536
+ },
537
+ ];
415
538
  mockGetPrPatch.mockReturnValue(prPatch);
539
+ mockGetPrCommentImports.mockResolvedValue(prCommentImports);
416
540
  const program = new Command();
417
541
  program
418
542
  .argument('[commit-ish]', 'commit-ish', 'HEAD')
419
543
  .argument('[compare-with]', 'compare-with')
544
+ .option('--comment <json>', 'comment', (value, previous = []) => [...previous, value], [])
420
545
  .option('--port <port>', 'port', parseInt)
421
546
  .option('--host <host>', 'host', '')
422
547
  .option('--no-open', 'no-open')
@@ -424,11 +549,15 @@ describe('CLI index.ts', () => {
424
549
  .option('--tui', 'tui')
425
550
  .option('--pr <url>', 'pr')
426
551
  .action(async (commitish, _compareWith, options) => {
552
+ const manualCommentImports = actualParseCommentOptions(options.comment);
553
+ let commentImports = manualCommentImports;
427
554
  if (options.pr) {
428
555
  if (commitish !== 'HEAD' || _compareWith) {
429
556
  console.error('Error: --pr option cannot be used with positional arguments');
430
557
  process.exit(1);
431
558
  }
559
+ const importedPrComments = await getPrCommentImports(options.pr);
560
+ commentImports = [...importedPrComments, ...manualCommentImports];
432
561
  }
433
562
  await startServer({
434
563
  stdinDiff: getPrPatch(options.pr),
@@ -436,10 +565,82 @@ describe('CLI index.ts', () => {
436
565
  host: options.host,
437
566
  openBrowser: options.open,
438
567
  mode: options.mode,
568
+ commentImports,
439
569
  });
440
570
  });
441
- await program.parseAsync(['--pr', prUrl], { from: 'user' });
571
+ await program.parseAsync([
572
+ '--pr',
573
+ prUrl,
574
+ '--comment',
575
+ '{"type":"reply","filePath":"src/example.ts","position":{"side":"new","line":10},"body":"Manual reply"}',
576
+ ], { from: 'user' });
442
577
  expect(mockGetPrPatch).toHaveBeenCalledWith(prUrl);
578
+ expect(mockGetPrCommentImports).toHaveBeenCalledWith(prUrl);
579
+ expect(mockStartServer).toHaveBeenCalledWith({
580
+ stdinDiff: prPatch,
581
+ preferredPort: undefined,
582
+ host: '',
583
+ openBrowser: true,
584
+ mode: 'split',
585
+ commentImports: [
586
+ ...prCommentImports,
587
+ {
588
+ type: 'reply',
589
+ id: undefined,
590
+ filePath: 'src/example.ts',
591
+ position: { side: 'new', line: 10 },
592
+ body: 'Manual reply',
593
+ author: undefined,
594
+ createdAt: undefined,
595
+ updatedAt: undefined,
596
+ codeSnapshot: undefined,
597
+ },
598
+ ],
599
+ });
600
+ });
601
+ it('continues with patch only when PR comment import fetch fails', async () => {
602
+ const prUrl = 'https://github.com/owner/repo/pull/123';
603
+ const prPatch = 'diff --git a/file.ts b/file.ts\nindex 1111111..2222222 100644\n';
604
+ mockGetPrPatch.mockReturnValue(prPatch);
605
+ mockGetPrCommentImports.mockRejectedValue(new Error('gh api graphql failed'));
606
+ const program = new Command();
607
+ program
608
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
609
+ .argument('[compare-with]', 'compare-with')
610
+ .option('--comment <json>', 'comment', (value, previous = []) => [...previous, value], [])
611
+ .option('--port <port>', 'port', parseInt)
612
+ .option('--host <host>', 'host', '')
613
+ .option('--no-open', 'no-open')
614
+ .option('--mode <mode>', 'mode', normalizeDiffViewMode, DEFAULT_DIFF_VIEW_MODE)
615
+ .option('--tui', 'tui')
616
+ .option('--pr <url>', 'pr')
617
+ .action(async (commitish, _compareWith, options) => {
618
+ const manualCommentImports = actualParseCommentOptions(options.comment);
619
+ let commentImports = manualCommentImports;
620
+ if (options.pr) {
621
+ if (commitish !== 'HEAD' || _compareWith) {
622
+ console.error('Error: --pr option cannot be used with positional arguments');
623
+ process.exit(1);
624
+ }
625
+ try {
626
+ const importedPrComments = await getPrCommentImports(options.pr);
627
+ commentImports = [...importedPrComments, ...manualCommentImports];
628
+ }
629
+ catch (error) {
630
+ console.warn(`Warning: Failed to load PR review comments: ${error instanceof Error ? error.message : 'Unknown error'}`);
631
+ }
632
+ }
633
+ await startServer({
634
+ stdinDiff: getPrPatch(options.pr),
635
+ preferredPort: options.port,
636
+ host: options.host,
637
+ openBrowser: options.open,
638
+ mode: options.mode,
639
+ ...(commentImports.length > 0 ? { commentImports } : {}),
640
+ });
641
+ });
642
+ await program.parseAsync(['--pr', prUrl], { from: 'user' });
643
+ expect(console.warn).toHaveBeenCalledWith('Warning: Failed to load PR review comments: gh api graphql failed');
443
644
  expect(mockStartServer).toHaveBeenCalledWith({
444
645
  stdinDiff: prPatch,
445
646
  preferredPort: undefined,
@@ -503,6 +704,95 @@ describe('CLI index.ts', () => {
503
704
  expect(mockStartServer).not.toHaveBeenCalled();
504
705
  });
505
706
  });
707
+ describe('--comment option', () => {
708
+ it('passes parsed comment imports to startServer', async () => {
709
+ const program = new Command();
710
+ program
711
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
712
+ .option('--comment <json>', 'comment', (value, previous = []) => [...previous, value], [])
713
+ .option('--port <port>', 'port', parseInt)
714
+ .option('--host <host>', 'host', '')
715
+ .option('--no-open', 'no-open')
716
+ .option('--mode <mode>', 'mode', normalizeDiffViewMode, DEFAULT_DIFF_VIEW_MODE)
717
+ .action(async (commitish, options) => {
718
+ const commentImports = actualParseCommentOptions(options.comment);
719
+ await startServer({
720
+ targetCommitish: commitish,
721
+ baseCommitish: `${commitish}^`,
722
+ preferredPort: options.port,
723
+ host: options.host,
724
+ openBrowser: options.open,
725
+ mode: options.mode,
726
+ commentImports,
727
+ });
728
+ });
729
+ await program.parseAsync([
730
+ '--comment',
731
+ '{"type":"thread","filePath":"src/example.ts","position":{"side":"new","line":10},"body":"Imported comment"}',
732
+ ], { from: 'user' });
733
+ expect(mockStartServer).toHaveBeenCalledWith({
734
+ targetCommitish: 'HEAD',
735
+ baseCommitish: 'HEAD^',
736
+ preferredPort: undefined,
737
+ host: '',
738
+ openBrowser: true,
739
+ mode: 'split',
740
+ commentImports: [
741
+ {
742
+ type: 'thread',
743
+ id: undefined,
744
+ filePath: 'src/example.ts',
745
+ position: { side: 'new', line: 10 },
746
+ body: 'Imported comment',
747
+ author: undefined,
748
+ createdAt: undefined,
749
+ updatedAt: undefined,
750
+ codeSnapshot: undefined,
751
+ },
752
+ ],
753
+ });
754
+ });
755
+ it('rejects --comment with --tui', async () => {
756
+ const program = new Command();
757
+ program
758
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
759
+ .option('--comment <json>', 'comment', (value, previous = []) => [...previous, value], [])
760
+ .option('--tui', 'tui')
761
+ .action(async (_commitish, options) => {
762
+ const commentImports = actualParseCommentOptions(options.comment);
763
+ if (options.tui && commentImports.length > 0) {
764
+ console.error('Error: --comment option cannot be used with --tui');
765
+ process.exit(1);
766
+ }
767
+ });
768
+ await program.parseAsync([
769
+ '--tui',
770
+ '--comment',
771
+ '{"type":"thread","filePath":"src/example.ts","position":{"side":"new","line":10},"body":"Imported comment"}',
772
+ ], { from: 'user' });
773
+ expect(console.error).toHaveBeenCalledWith('Error: --comment option cannot be used with --tui');
774
+ expect(process.exit).toHaveBeenCalledWith(1);
775
+ });
776
+ it('reports invalid comment json before starting the server', async () => {
777
+ const program = new Command();
778
+ program
779
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
780
+ .option('--comment <json>', 'comment', (value, previous = []) => [...previous, value], [])
781
+ .action(async (_commitish, options) => {
782
+ try {
783
+ actualParseCommentOptions(options.comment);
784
+ }
785
+ catch (error) {
786
+ console.error(`Error: ${error instanceof Error ? error.message : 'Invalid --comment value'}`);
787
+ process.exit(1);
788
+ }
789
+ });
790
+ await program.parseAsync(['--comment', '{'], { from: 'user' });
791
+ expect(console.error).toHaveBeenCalledWith('Error: Invalid --comment JSON');
792
+ expect(process.exit).toHaveBeenCalledWith(1);
793
+ expect(mockStartServer).not.toHaveBeenCalled();
794
+ });
795
+ });
506
796
  describe('Clean flag functionality', () => {
507
797
  it('displays clean message when flag is used', async () => {
508
798
  mockFindUntrackedFiles.mockResolvedValue([]);
@@ -872,6 +1162,42 @@ describe('CLI index.ts', () => {
872
1162
  },
873
1163
  });
874
1164
  });
1165
+ it('passes context option to TUI app', async () => {
1166
+ mockFindUntrackedFiles.mockResolvedValue([]);
1167
+ const program = new Command();
1168
+ program
1169
+ .argument('[commit-ish]', 'commit-ish', 'HEAD')
1170
+ .argument('[compare-with]', 'compare-with')
1171
+ .option('--port <port>', 'port', parseInt)
1172
+ .option('--host <host>', 'host', '')
1173
+ .option('--no-open', 'no-open')
1174
+ .option('--mode <mode>', 'mode', normalizeDiffViewMode, DEFAULT_DIFF_VIEW_MODE)
1175
+ .option('--context <lines>', 'context', parseInt)
1176
+ .option('--tui', 'tui')
1177
+ .option('--pr <url>', 'pr')
1178
+ .action(async (commitish, _compareWith, options) => {
1179
+ if (options.tui) {
1180
+ const { render } = await import('ink');
1181
+ const { default: TuiApp } = await import('../tui/App.js');
1182
+ render(React.createElement(TuiApp, {
1183
+ targetCommitish: commitish,
1184
+ baseCommitish: commitish + '^',
1185
+ mode: options.mode,
1186
+ contextLines: options.context,
1187
+ }));
1188
+ }
1189
+ });
1190
+ await program.parseAsync(['--tui', '--context', '2'], { from: 'user' });
1191
+ expect(mockRender).toHaveBeenCalledWith({
1192
+ component: mockTuiApp,
1193
+ props: {
1194
+ targetCommitish: 'HEAD',
1195
+ baseCommitish: 'HEAD^',
1196
+ mode: 'split',
1197
+ contextLines: 2,
1198
+ },
1199
+ });
1200
+ });
875
1201
  it('passes mode option to TUI app', async () => {
876
1202
  mockFindUntrackedFiles.mockResolvedValue([]);
877
1203
  const program = new Command();
@@ -1,5 +1,6 @@
1
1
  import { type Stats } from 'node:fs';
2
2
  import type { SimpleGit } from 'simple-git';
3
+ import type { CommentImport } from '../types/diff.js';
3
4
  type StdinStat = Pick<Stats, 'isFIFO' | 'isFile' | 'isSocket'>;
4
5
  type StdinSource = 'pipe' | 'file' | 'socket' | 'tty';
5
6
  export declare function detectStdinSource(stdinStat?: StdinStat): StdinSource;
@@ -15,14 +16,7 @@ export declare function getGitRoot(): string;
15
16
  export declare function validateCommitish(commitish: string): boolean;
16
17
  export declare function shortHash(hash: string): string;
17
18
  export declare function createCommitRangeString(baseHash: string, targetHash: string): string;
18
- interface PullRequestInfo {
19
- owner: string;
20
- repo: string;
21
- pullNumber: number;
22
- hostname: string;
23
- }
24
- export declare function parseGitHubPrUrl(url: string): PullRequestInfo | null;
25
- export declare function getPrPatch(prArg: string): string;
19
+ export declare function parseCommentOptions(commentValues: string[]): CommentImport[];
26
20
  export declare function validateDiffArguments(targetCommitish: string, baseCommitish?: string): {
27
21
  valid: boolean;
28
22
  error?: string;
package/dist/cli/utils.js CHANGED
@@ -1,6 +1,7 @@
1
- import { execFileSync, execSync } from 'child_process';
1
+ import { execSync } from 'child_process';
2
2
  import { fstatSync } from 'node:fs';
3
3
  import { createInterface } from 'readline/promises';
4
+ import { parseCommentImportValue } from '../utils/commentImports.js';
4
5
  export function detectStdinSource(stdinStat = fstatSync(0)) {
5
6
  if (stdinStat.isFIFO()) {
6
7
  return 'pipe';
@@ -129,48 +130,8 @@ export function shortHash(hash) {
129
130
  export function createCommitRangeString(baseHash, targetHash) {
130
131
  return `${baseHash}...${targetHash}`;
131
132
  }
132
- export function parseGitHubPrUrl(url) {
133
- try {
134
- const urlObj = new URL(url);
135
- // Allow any hostname for GitHub Enterprise support
136
- // Just validate the path structure
137
- const pathParts = urlObj.pathname.split('/').filter(Boolean);
138
- if (pathParts.length < 4 || pathParts[2] !== 'pull') {
139
- return null;
140
- }
141
- const owner = pathParts[0];
142
- const repo = pathParts[1];
143
- const pullNumber = parseInt(pathParts[3], 10);
144
- if (isNaN(pullNumber)) {
145
- return null;
146
- }
147
- return { owner, repo, pullNumber, hostname: urlObj.hostname };
148
- }
149
- catch {
150
- return null;
151
- }
152
- }
153
- export function getPrPatch(prArg) {
154
- try {
155
- const patch = execFileSync('gh', ['pr', 'diff', prArg], {
156
- encoding: 'utf8',
157
- stdio: ['ignore', 'pipe', 'pipe'],
158
- });
159
- if (!patch.trim()) {
160
- throw new Error('No diff content returned from gh pr diff');
161
- }
162
- return patch;
163
- }
164
- catch (error) {
165
- const stderr = error.stderr;
166
- const stderrText = typeof stderr === 'string'
167
- ? stderr.trim()
168
- : Buffer.isBuffer(stderr)
169
- ? stderr.toString('utf8').trim()
170
- : '';
171
- const message = stderrText || (error instanceof Error ? error.message : 'Unknown error while running gh');
172
- throw new Error(`${message}\nTry: gh auth login`);
173
- }
133
+ export function parseCommentOptions(commentValues) {
134
+ return commentValues.flatMap((value) => parseCommentImportValue(value));
174
135
  }
175
136
  export function validateDiffArguments(targetCommitish, baseCommitish) {
176
137
  // Validate target commitish format