@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.2

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 (117) hide show
  1. package/CHANGELOG.md +172 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +10 -1
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/edit/index.d.ts +6 -0
  12. package/dist/types/edit/streaming.d.ts +8 -0
  13. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/export/ttsr.d.ts +9 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  17. package/dist/types/main.d.ts +5 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +19 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  21. package/dist/types/modes/components/session-selector.d.ts +32 -5
  22. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  23. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  24. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +9 -2
  26. package/dist/types/modes/types.d.ts +4 -2
  27. package/dist/types/registry/agent-registry.d.ts +1 -1
  28. package/dist/types/sdk.d.ts +2 -2
  29. package/dist/types/session/agent-session.d.ts +4 -2
  30. package/dist/types/session/history-storage.d.ts +16 -1
  31. package/dist/types/session/session-manager.d.ts +4 -0
  32. package/dist/types/task/output-manager.d.ts +6 -15
  33. package/dist/types/tools/find.d.ts +0 -9
  34. package/dist/types/tools/index.d.ts +1 -1
  35. package/dist/types/tools/path-utils.d.ts +16 -0
  36. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  37. package/dist/types/tools/write.d.ts +2 -0
  38. package/dist/types/utils/clipboard.d.ts +4 -0
  39. package/dist/types/utils/jj.d.ts +49 -0
  40. package/dist/types/web/kagi.d.ts +76 -0
  41. package/dist/types/web/search/providers/exa.d.ts +7 -1
  42. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  43. package/package.json +9 -9
  44. package/src/async/job-manager.ts +3 -3
  45. package/src/cli/args.ts +6 -2
  46. package/src/cli/claude-trace-cli.ts +783 -0
  47. package/src/cli/session-picker.ts +36 -10
  48. package/src/cli/update-cli.ts +35 -2
  49. package/src/commands/launch.ts +3 -0
  50. package/src/config/keybindings.ts +14 -1
  51. package/src/config/model-registry.ts +18 -7
  52. package/src/config/settings-schema.ts +2 -2
  53. package/src/config/settings.ts +23 -0
  54. package/src/discovery/builtin-rules/index.ts +2 -0
  55. package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +44 -0
  56. package/src/discovery/claude-plugins.ts +7 -9
  57. package/src/edit/index.ts +10 -0
  58. package/src/edit/streaming.ts +65 -0
  59. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  60. package/src/eval/concurrency-bridge.ts +34 -0
  61. package/src/eval/js/shared/prelude.txt +20 -17
  62. package/src/eval/js/tool-bridge.ts +5 -0
  63. package/src/eval/py/prelude.py +23 -15
  64. package/src/export/ttsr.ts +18 -1
  65. package/src/extensibility/custom-commands/bundled/review/index.ts +74 -45
  66. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  67. package/src/extensibility/skills.ts +0 -1
  68. package/src/internal-urls/docs-index.generated.ts +12 -11
  69. package/src/main.ts +92 -24
  70. package/src/mcp/transports/stdio.ts +55 -22
  71. package/src/modes/acp/acp-event-mapper.ts +54 -4
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/hook-selector.ts +89 -31
  74. package/src/modes/components/oauth-selector.ts +12 -6
  75. package/src/modes/components/session-selector.ts +179 -24
  76. package/src/modes/components/tool-execution.ts +16 -3
  77. package/src/modes/controllers/command-controller.ts +2 -11
  78. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  79. package/src/modes/controllers/input-controller.ts +19 -1
  80. package/src/modes/controllers/selector-controller.ts +61 -21
  81. package/src/modes/interactive-mode.ts +125 -15
  82. package/src/modes/types.ts +5 -2
  83. package/src/prompts/agents/reviewer.md +2 -2
  84. package/src/prompts/review-request.md +1 -1
  85. package/src/prompts/system/empty-stop-retry.md +6 -0
  86. package/src/prompts/system/orchestrate-notice.md +5 -3
  87. package/src/prompts/system/workflow-notice.md +2 -2
  88. package/src/prompts/tools/eval.md +5 -5
  89. package/src/prompts/tools/find.md +1 -1
  90. package/src/prompts/tools/irc.md +6 -6
  91. package/src/prompts/tools/search-tool-bm25.md +9 -2
  92. package/src/prompts/tools/search.md +1 -1
  93. package/src/prompts/tools/task.md +1 -1
  94. package/src/registry/agent-registry.ts +1 -1
  95. package/src/sdk.ts +85 -31
  96. package/src/session/agent-session.ts +209 -54
  97. package/src/session/history-storage.ts +56 -12
  98. package/src/session/session-manager.ts +34 -0
  99. package/src/task/output-manager.ts +40 -48
  100. package/src/task/render.ts +3 -8
  101. package/src/tools/browser/tab-worker.ts +8 -5
  102. package/src/tools/find.ts +5 -29
  103. package/src/tools/index.ts +1 -1
  104. package/src/tools/path-utils.ts +144 -1
  105. package/src/tools/read.ts +47 -0
  106. package/src/tools/search-tool-bm25.ts +7 -1
  107. package/src/tools/search.ts +2 -27
  108. package/src/tools/sqlite-reader.ts +92 -9
  109. package/src/tools/write.ts +6 -0
  110. package/src/utils/clipboard.ts +38 -1
  111. package/src/utils/git.ts +19 -23
  112. package/src/utils/jj.ts +225 -0
  113. package/src/utils/open.ts +37 -2
  114. package/src/web/kagi.ts +168 -49
  115. package/src/web/search/providers/anthropic.ts +1 -1
  116. package/src/web/search/providers/exa.ts +20 -86
  117. package/src/web/search/providers/kagi.ts +4 -0
@@ -55,15 +55,24 @@ if (!globalThis.__omp_js_prelude_loaded__) {
55
55
  return hasOwn(o, "schema") ? JSON.parse(text) : text;
56
56
  };
57
57
 
58
- const normalizeConcurrency = value => {
59
- const number = Number(value ?? 4);
60
- if (!Number.isFinite(number)) return 4;
61
- return Math.max(1, Math.min(16, Math.trunc(number)));
58
+ // Pool ceiling mirrors the task tool's `task.maxConcurrency` setting so an
59
+ // eval fan-out runs as wide as a `task` batch would. 0 (and any non-finite
60
+ // reply) means unbounded — run every item at once.
61
+ const __concurrencyLimit = async () => {
62
+ try {
63
+ const r = await globalThis.__omp_call_tool__("__concurrency__", {});
64
+ const n = Math.trunc(Number(r && typeof r === "object" ? r.limit : r));
65
+ return Number.isFinite(n) && n > 0 ? n : 0;
66
+ } catch {
67
+ return 0;
68
+ }
62
69
  };
63
70
 
64
- const __pool = async (items, limit, fn) => {
71
+ const __pool = async (items, fn) => {
65
72
  const list = Array.from(items ?? []);
66
- const concurrency = Math.min(normalizeConcurrency(limit), list.length);
73
+ if (list.length === 0) return [];
74
+ const limit = await __concurrencyLimit();
75
+ const concurrency = limit > 0 ? Math.min(limit, list.length) : list.length;
67
76
  const results = new Array(list.length);
68
77
  let next = 0;
69
78
  const worker = async () => {
@@ -77,23 +86,17 @@ if (!globalThis.__omp_js_prelude_loaded__) {
77
86
  return results;
78
87
  };
79
88
 
80
- const parallel = async (thunks, opts = {}) =>
81
- __pool(thunks, toOptions(opts).concurrency, (thunk, index) => {
89
+ const parallel = async thunks =>
90
+ __pool(thunks, (thunk, index) => {
82
91
  if (typeof thunk !== "function") throw new TypeError("parallel() expects an iterable of functions");
83
92
  return thunk(index);
84
93
  });
85
94
 
86
- const pipeline = async (items, ...stagesAndOptions) => {
87
- let opts = {};
88
- const last = stagesAndOptions.at(-1);
89
- if (last && typeof last === "object" && !Array.isArray(last)) {
90
- opts = last;
91
- stagesAndOptions = stagesAndOptions.slice(0, -1);
92
- }
95
+ const pipeline = async (items, ...stages) => {
93
96
  let current = Array.from(items ?? []);
94
- for (const stage of stagesAndOptions) {
97
+ for (const stage of stages) {
95
98
  if (typeof stage !== "function") throw new TypeError("pipeline() stages must be functions");
96
- current = await __pool(current, toOptions(opts).concurrency, stage);
99
+ current = await __pool(current, stage);
97
100
  }
98
101
  return current;
99
102
  };
@@ -3,6 +3,7 @@ import type { ToolSession } from "../../tools";
3
3
  import { ToolError } from "../../tools/tool-errors";
4
4
  import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge";
5
5
  import { EVAL_BUDGET_BRIDGE_NAME, type EvalBudgetResult, runEvalBudget } from "../budget-bridge";
6
+ import { EVAL_CONCURRENCY_BRIDGE_NAME, type EvalConcurrencyResult, runEvalConcurrency } from "../concurrency-bridge";
6
7
  import { EVAL_LLM_BRIDGE_NAME, runEvalLlm } from "../llm-bridge";
7
8
  import type { JsStatusEvent } from "./shared/types";
8
9
 
@@ -17,6 +18,7 @@ interface ToolBridgeOptions {
17
18
  type ToolValue =
18
19
  | string
19
20
  | EvalBudgetResult
21
+ | EvalConcurrencyResult
20
22
  | {
21
23
  text: string;
22
24
  details?: unknown;
@@ -114,6 +116,9 @@ export async function callSessionTool(name: string, args: unknown, options: Tool
114
116
  if (name === EVAL_BUDGET_BRIDGE_NAME) {
115
117
  return await runEvalBudget(args, options);
116
118
  }
119
+ if (name === EVAL_CONCURRENCY_BRIDGE_NAME) {
120
+ return runEvalConcurrency(args, options);
121
+ }
117
122
  const tool = getTool(options.session, name);
118
123
  const normalizedArgs = normalizeArgs(args);
119
124
  const toolCallId = `js-${name}-${crypto.randomUUID()}`;
@@ -503,27 +503,35 @@ if "__omp_prelude_loaded__" not in globals():
503
503
  text = res.get("text") if isinstance(res, dict) else res
504
504
  return json.loads(text) if schema is not None else text
505
505
 
506
- def _normalize_concurrency(value):
507
- """Clamp a concurrency hint to [1, 16], defaulting to 4 on bad input."""
506
+ def _concurrency_limit():
507
+ """Worker-pool ceiling from the host ``task.maxConcurrency`` setting.
508
+
509
+ An eval fan-out runs as wide as a ``task`` batch would. Returns ``0`` for
510
+ unbounded (run every item at once); falls back to ``0`` if the host
511
+ bridge is unreachable.
512
+ """
508
513
  try:
509
- n = int(value)
510
- except (TypeError, ValueError):
511
- n = 4
512
- return max(1, min(16, n))
514
+ snap = _bridge_call("__concurrency__", {}) or {}
515
+ n = int(snap.get("limit") or 0)
516
+ except Exception:
517
+ return 0
518
+ return n if n > 0 else 0
513
519
 
514
- def _pool_map(items, fn, concurrency):
520
+ def _pool_map(items, fn):
515
521
  """Run ``fn`` over ``items`` through a bounded thread pool.
516
522
 
517
523
  Preserves input order, barriers until every task settles, and raises the
518
524
  lowest-index exception if any task failed. Each task runs inside a copy
519
525
  of the submitting thread's context so the ``_CURRENT_RID`` ContextVar
520
- propagates and bridge calls (agent(), tool.*, etc.) keep working.
526
+ propagates and bridge calls (agent(), tool.*, etc.) keep working. The
527
+ pool width tracks ``task.maxConcurrency`` (0 = run every item at once).
521
528
  """
522
529
  import concurrent.futures, contextvars
523
530
  items = list(items)
524
531
  if not items:
525
532
  return []
526
- workers = min(_normalize_concurrency(concurrency), len(items))
533
+ limit = _concurrency_limit()
534
+ workers = min(limit, len(items)) if limit > 0 else len(items)
527
535
  results = [None] * len(items)
528
536
  errors = {}
529
537
  with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
@@ -541,30 +549,30 @@ if "__omp_prelude_loaded__" not in globals():
541
549
  raise errors[min(errors)]
542
550
  return results
543
551
 
544
- def parallel(thunks, *, concurrency=4):
552
+ def parallel(thunks):
545
553
  """Run zero-arg callables through a bounded pool, preserving input order.
546
554
 
547
555
  Barriers until all finish; re-raises the lowest-index exception if any
548
- thunk raised.
556
+ thunk raised. Pool width tracks the task tool's ``task.maxConcurrency``.
549
557
  """
550
558
  thunks = list(thunks)
551
559
  for t in thunks:
552
560
  if not callable(t):
553
561
  raise TypeError("parallel() expects an iterable of zero-arg callables")
554
- return _pool_map(thunks, lambda t: t(), concurrency)
562
+ return _pool_map(thunks, lambda t: t())
555
563
 
556
- def pipeline(items, *stages, concurrency=4):
564
+ def pipeline(items, *stages):
557
565
  """Map items left-to-right through one-arg stage callables.
558
566
 
559
567
  Every item clears stage N before any item enters stage N+1 (barrier per
560
568
  stage). Stage 1 receives the original item; later stages receive the
561
- previous stage's result.
569
+ previous stage's result. Pool width tracks ``task.maxConcurrency``.
562
570
  """
563
571
  current = list(items)
564
572
  for stage in stages:
565
573
  if not callable(stage):
566
574
  raise TypeError("pipeline() stages must be callables")
567
- current = _pool_map(current, stage, concurrency)
575
+ current = _pool_map(current, stage)
568
576
  return current
569
577
 
570
578
  def log(message):
@@ -339,7 +339,24 @@ export class TtsrManager {
339
339
  const bufferKey = this.#bufferKey(context);
340
340
  const nextBuffer = `${this.#buffers.get(bufferKey) ?? ""}${delta}`;
341
341
  this.#buffers.set(bufferKey, nextBuffer);
342
+ return this.#matchBuffer(nextBuffer, context);
343
+ }
344
+
345
+ /**
346
+ * Replace the scoped buffer with a tool-provided normalized snapshot and
347
+ * return matching rules.
348
+ *
349
+ * Used for tools exposing `matcherDigest`: the digest is recomputed from the
350
+ * full (partial) arguments on every delta, so it replaces the buffer instead
351
+ * of being appended to it.
352
+ */
353
+ checkSnapshot(snapshot: string, context: TtsrMatchContext): Rule[] {
354
+ const bufferKey = this.#bufferKey(context);
355
+ this.#buffers.set(bufferKey, snapshot);
356
+ return this.#matchBuffer(snapshot, context);
357
+ }
342
358
 
359
+ #matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
343
360
  const matches: Rule[] = [];
344
361
  for (const [name, entry] of this.#rules) {
345
362
  if (!this.#canTrigger(name)) {
@@ -351,7 +368,7 @@ export class TtsrManager {
351
368
  if (!this.#matchesGlobalPaths(entry, context)) {
352
369
  continue;
353
370
  }
354
- if (!this.#matchesCondition(entry, nextBuffer)) {
371
+ if (!this.#matchesCondition(entry, buffer)) {
355
372
  continue;
356
373
  }
357
374
 
@@ -7,7 +7,7 @@
7
7
  * 3. Review a specific commit
8
8
  * 4. Custom review instructions
9
9
  *
10
- * Runs git diff upfront, parses results, filters noise, and provides
10
+ * Runs VCS diffs upfront, parses results, filters noise, and provides
11
11
  * rich context for the orchestrating agent to distribute work across
12
12
  * multiple reviewer agents based on diff weight and locality.
13
13
  */
@@ -18,6 +18,7 @@ import reviewCustomRequestTemplate from "../../../../prompts/review-custom-reque
18
18
  import reviewHeadlessRequestTemplate from "../../../../prompts/review-headless-request.md" with { type: "text" };
19
19
  import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
20
20
  import * as git from "../../../../utils/git";
21
+ import * as jj from "../../../../utils/jj";
21
22
 
22
23
  // ─────────────────────────────────────────────────────────────────────────────
23
24
  // Types
@@ -37,6 +38,13 @@ interface DiffStats {
37
38
  excluded: { path: string; reason: string; linesAdded: number; linesRemoved: number }[];
38
39
  }
39
40
 
41
+ interface CurrentReviewDiff {
42
+ diffInstruction: string;
43
+ diffText: string;
44
+ emptyMessage?: string;
45
+ mode: string;
46
+ }
47
+
40
48
  // ─────────────────────────────────────────────────────────────────────────────
41
49
  // Exclusion patterns for noise files
42
50
  // ─────────────────────────────────────────────────────────────────────────────
@@ -195,11 +203,20 @@ function getDiffPreview(hunks: string, maxLines: number): string {
195
203
  // Thresholds for diff inclusion
196
204
  const MAX_DIFF_CHARS = 50_000; // Don't include diff above this
197
205
  const MAX_FILES_FOR_INLINE_DIFF = 20; // Don't include diff if more files than this
206
+ const DEFAULT_LARGE_DIFF_INSTRUCTION = "MUST run `git diff`/`git show` for assigned files";
207
+ const GIT_UNCOMMITTED_DIFF_INSTRUCTION =
208
+ "MUST run both `git diff -- <path>` and `git diff --cached -- <path>` for assigned files";
209
+ const JJ_UNCOMMITTED_DIFF_INSTRUCTION = "MUST run `jj --ignore-working-copy diff --git -- <path>` for assigned files";
198
210
 
199
211
  /**
200
212
  * Build the full review prompt with diff stats and distribution guidance.
201
213
  */
202
- function buildReviewPrompt(mode: string, stats: DiffStats, rawDiff: string, additionalInstructions?: string): string {
214
+ function buildReviewPrompt(
215
+ mode: string,
216
+ stats: DiffStats,
217
+ rawDiff: string,
218
+ options: { additionalInstructions?: string; diffInstruction?: string } = {},
219
+ ): string {
203
220
  const agentCount = getRecommendedAgentCount(stats);
204
221
  const skipDiff = rawDiff.length > MAX_DIFF_CHARS || stats.files.length > MAX_FILES_FOR_INLINE_DIFF;
205
222
  const totalLines = stats.totalAdded + stats.totalRemoved;
@@ -223,7 +240,8 @@ function buildReviewPrompt(mode: string, stats: DiffStats, rawDiff: string, addi
223
240
  skipDiff,
224
241
  rawDiff: rawDiff.trim(),
225
242
  linesPerFile,
226
- additionalInstructions,
243
+ additionalInstructions: options.additionalInstructions,
244
+ diffInstruction: options.diffInstruction ?? DEFAULT_LARGE_DIFF_INSTRUCTION,
227
245
  });
228
246
  }
229
247
 
@@ -305,49 +323,32 @@ export class ReviewCommand implements CustomCommand {
305
323
  `Reviewing changes between \`${baseBranch}\` and \`${currentBranch}\` (PR-style)`,
306
324
  stats,
307
325
  diffText,
308
- extraInstructions,
326
+ { additionalInstructions: extraInstructions },
309
327
  );
310
328
  }
311
329
 
312
330
  case 2: {
313
- // Uncommitted changes - combine staged and unstaged
314
- const status = await getGitStatus(this.api);
315
- if (!status.trim()) {
316
- ctx.ui.notify("No uncommitted changes found", "warning");
317
- return undefined;
318
- }
319
-
320
- let unstagedDiff: string;
321
- let stagedDiff: string;
322
- try {
323
- [unstagedDiff, stagedDiff] = await Promise.all([
324
- git.diff(this.api.cwd),
325
- git.diff(this.api.cwd, { cached: true }),
326
- ]);
327
- } catch (err) {
331
+ const reviewDiff = await getUncommittedReviewDiff(this.api).catch(err => {
328
332
  ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error");
329
333
  return undefined;
330
- }
334
+ });
335
+ if (!reviewDiff) return undefined;
331
336
 
332
- const combinedDiff = [unstagedDiff, stagedDiff].filter(Boolean).join("\n");
333
-
334
- if (!combinedDiff.trim()) {
335
- ctx.ui.notify("No diff content found", "warning");
337
+ if (!reviewDiff.diffText.trim()) {
338
+ ctx.ui.notify(reviewDiff.emptyMessage ?? "No diff content found", "warning");
336
339
  return undefined;
337
340
  }
338
341
 
339
- const stats = parseDiff(combinedDiff);
342
+ const stats = parseDiff(reviewDiff.diffText);
340
343
  if (stats.files.length === 0) {
341
344
  ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
342
345
  return undefined;
343
346
  }
344
347
 
345
- return buildReviewPrompt(
346
- "Reviewing uncommitted changes (staged + unstaged)",
347
- stats,
348
- combinedDiff,
349
- extraInstructions,
350
- );
348
+ return buildReviewPrompt(reviewDiff.mode, stats, reviewDiff.diffText, {
349
+ additionalInstructions: extraInstructions,
350
+ diffInstruction: reviewDiff.diffInstruction,
351
+ });
351
352
  }
352
353
 
353
354
  case 3: {
@@ -383,11 +384,13 @@ export class ReviewCommand implements CustomCommand {
383
384
  return undefined;
384
385
  }
385
386
 
386
- return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, diffText, extraInstructions);
387
+ return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, diffText, {
388
+ additionalInstructions: extraInstructions,
389
+ });
387
390
  }
388
391
 
389
392
  case 4: {
390
- // Custom instructions - still uses the old approach since user provides context
393
+ // Custom instructions with opportunistic current-diff context.
391
394
  const instructions = await ctx.ui.editor(
392
395
  "Enter custom review instructions",
393
396
  "Review the following:\n\n",
@@ -396,23 +399,19 @@ export class ReviewCommand implements CustomCommand {
396
399
  );
397
400
  if (!instructions?.trim()) return undefined;
398
401
 
399
- // For custom, we still try to get current diff for context
400
- let diffText: string | undefined;
401
- try {
402
- diffText = await git.diff(this.api.cwd, { base: "HEAD" });
403
- } catch {
404
- diffText = undefined;
405
- }
406
- const reviewDiff = diffText?.trim();
402
+ const reviewDiff = await getUncommittedReviewDiff(this.api).catch(() => undefined);
407
403
 
408
- if (reviewDiff) {
409
- const stats = parseDiff(reviewDiff);
404
+ if (reviewDiff?.diffText.trim()) {
405
+ const stats = parseDiff(reviewDiff.diffText);
410
406
  // Even if all files filtered, include the custom instructions
411
407
  return buildReviewPrompt(
412
408
  `Custom review: ${instructions.split("\n")[0].slice(0, 60)}…`,
413
409
  stats,
414
- reviewDiff,
415
- instructions,
410
+ reviewDiff.diffText,
411
+ {
412
+ additionalInstructions: instructions,
413
+ diffInstruction: reviewDiff.diffInstruction,
414
+ },
416
415
  );
417
416
  }
418
417
 
@@ -449,6 +448,36 @@ async function getGitStatus(api: CustomCommandAPI): Promise<string> {
449
448
  }
450
449
  }
451
450
 
451
+ async function getUncommittedReviewDiff(api: CustomCommandAPI): Promise<CurrentReviewDiff> {
452
+ if (await jj.repo.is(api.cwd)) {
453
+ return {
454
+ diffText: await jj.diff(api.cwd),
455
+ diffInstruction: JJ_UNCOMMITTED_DIFF_INSTRUCTION,
456
+ emptyMessage: "No uncommitted changes found",
457
+ mode: "Reviewing JJ working-copy changes",
458
+ };
459
+ }
460
+
461
+ const status = await getGitStatus(api);
462
+ if (!status.trim()) {
463
+ return {
464
+ diffText: "",
465
+ diffInstruction: GIT_UNCOMMITTED_DIFF_INSTRUCTION,
466
+ emptyMessage: "No uncommitted changes found",
467
+ mode: "Reviewing uncommitted changes (staged + unstaged)",
468
+ };
469
+ }
470
+
471
+ const [unstagedDiff, stagedDiff] = await Promise.all([git.diff(api.cwd), git.diff(api.cwd, { cached: true })]);
472
+ const combinedDiff = [unstagedDiff, stagedDiff].filter(Boolean).join("\n");
473
+ return {
474
+ diffText: combinedDiff,
475
+ diffInstruction: GIT_UNCOMMITTED_DIFF_INSTRUCTION,
476
+ emptyMessage: "No diff content found",
477
+ mode: "Reviewing uncommitted changes (staged + unstaged)",
478
+ };
479
+ }
480
+
452
481
  async function getRecentCommits(api: CustomCommandAPI, count: number): Promise<string[]> {
453
482
  try {
454
483
  return await git.log.onelines(api.cwd, count);