git-coco 0.53.0 → 0.54.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.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.53.0";
64
+ const BUILD_VERSION = "0.54.1";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -308,6 +308,25 @@ class LangChainNetworkError extends LangChainError {
308
308
  this.provider = provider;
309
309
  }
310
310
  }
311
+ /**
312
+ * User-initiated cancellation (#881 phase 3). Thrown by streaming
313
+ * helpers when an `AbortSignal` they were given fires. Distinct from
314
+ * `LangChainNetworkError` / `LangChainTimeoutError` so callers can
315
+ * pattern-match: a cancelled LLM call is the user's intent, not a
316
+ * failure to surface in the status line as an error.
317
+ *
318
+ * Carries the accumulated text up to the cancel point (when
319
+ * available) so the caller can decide whether to salvage a partial
320
+ * result or discard it. Today the workstation discards — the
321
+ * preview pane was the only consumer of the accumulated text and it
322
+ * gets cleared on cancel anyway.
323
+ */
324
+ class LangChainCancelledError extends LangChainError {
325
+ constructor(message, accumulated, context) {
326
+ super(message, { ...context, accumulated });
327
+ this.accumulated = accumulated;
328
+ }
329
+ }
311
330
 
312
331
  /**
313
332
  * Validates that a required parameter is not null or undefined
@@ -1302,6 +1321,18 @@ const schema$1 = {
1302
1321
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1303
1322
  "default": "balanced"
1304
1323
  },
1324
+ "streaming": {
1325
+ "type": "object",
1326
+ "properties": {
1327
+ "enabled": {
1328
+ "type": "boolean",
1329
+ "description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
1330
+ "default": false
1331
+ }
1332
+ },
1333
+ "additionalProperties": false,
1334
+ "description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
1335
+ },
1305
1336
  "fastPath": {
1306
1337
  "type": "object",
1307
1338
  "properties": {
@@ -1756,6 +1787,18 @@ const schema$1 = {
1756
1787
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1757
1788
  "default": "balanced"
1758
1789
  },
1790
+ "streaming": {
1791
+ "type": "object",
1792
+ "properties": {
1793
+ "enabled": {
1794
+ "type": "boolean",
1795
+ "description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
1796
+ "default": false
1797
+ }
1798
+ },
1799
+ "additionalProperties": false,
1800
+ "description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
1801
+ },
1759
1802
  "fastPath": {
1760
1803
  "type": "object",
1761
1804
  "properties": {
@@ -1950,6 +1993,18 @@ const schema$1 = {
1950
1993
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1951
1994
  "default": "balanced"
1952
1995
  },
1996
+ "streaming": {
1997
+ "type": "object",
1998
+ "properties": {
1999
+ "enabled": {
2000
+ "type": "boolean",
2001
+ "description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
2002
+ "default": false
2003
+ }
2004
+ },
2005
+ "additionalProperties": false,
2006
+ "description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
2007
+ },
1953
2008
  "fastPath": {
1954
2009
  "type": "object",
1955
2010
  "properties": {
@@ -7943,7 +7998,7 @@ async function enforcePromptBudget({ prompt, variables, tokenizer, maxTokens, su
7943
7998
  /**
7944
7999
  * Extracts provider and endpoint info from LLM instance if available
7945
8000
  */
7946
- function extractLlmInfo(llm) {
8001
+ function extractLlmInfo$1(llm) {
7947
8002
  const info = {};
7948
8003
  // Try to extract provider from class name
7949
8004
  const className = llm?.constructor?.name || '';
@@ -7986,7 +8041,7 @@ const executeChain = async ({ llm, prompt, variables, parser, provider, endpoint
7986
8041
  });
7987
8042
  }
7988
8043
  // Extract LLM info for error reporting if not provided
7989
- const llmInfo = extractLlmInfo(llm);
8044
+ const llmInfo = extractLlmInfo$1(llm);
7990
8045
  const effectiveProvider = provider || llmInfo.provider;
7991
8046
  const effectiveEndpoint = endpoint || llmInfo.endpoint;
7992
8047
  try {
@@ -14571,6 +14626,11 @@ const options$8 = {
14571
14626
  type: 'boolean',
14572
14627
  default: false,
14573
14628
  },
14629
+ strictSplit: {
14630
+ description: 'Fail loudly if the split planner exhausts its retry budget with an invalid plan (otherwise falls back to a single combined commit).',
14631
+ type: 'boolean',
14632
+ default: false,
14633
+ },
14574
14634
  };
14575
14635
  const builder$8 = (yargs) => {
14576
14636
  return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
@@ -15459,6 +15519,53 @@ function dropEmptyGroups(plan) {
15459
15519
  }
15460
15520
  return { ...plan, groups: surviving };
15461
15521
  }
15522
+ /**
15523
+ * Construct a trivially-valid single-group plan covering every staged
15524
+ * file. Used as the fallback when the LLM exhausts its retry budget
15525
+ * with an invalid plan — turning a hard failure into a usable
15526
+ * (if degraded) outcome.
15527
+ *
15528
+ * Properties of the returned plan:
15529
+ *
15530
+ * - Exactly one group.
15531
+ * - Every staged file appears in that group's `files[]`. No hunks
15532
+ * are claimed, so any hunk inventory is irrelevant to the plan's
15533
+ * validity.
15534
+ * - By construction: no duplicates, no missing files, no mixed
15535
+ * mode, no phantom hunks. `getPlanValidationIssues` returns an
15536
+ * empty issue set.
15537
+ *
15538
+ * The group's `rationale` carries the reason text the caller wants
15539
+ * to expose to the UI (typically "model exhausted N attempts; last
15540
+ * issues were …"). The `body` carries a short note that survives
15541
+ * into the commit message body so a user who applies without editing
15542
+ * has the context recorded in git history.
15543
+ *
15544
+ * `title` defaults to a generic conventional-commits-compatible
15545
+ * `chore: combined commit` — bland on purpose. Real commit messaging
15546
+ * is the user's job at the compose / apply step.
15547
+ *
15548
+ * The plan is NOT linked to the LLM by construction. If the model
15549
+ * can't produce a valid split, the user still gets one apply-able
15550
+ * commit instead of a thrown error and a still-staged worktree.
15551
+ */
15552
+ function buildSplitPlanFallback(staged, options = {}) {
15553
+ const files = staged.map((change) => change.filePath);
15554
+ const reasonLine = options.reason
15555
+ ? ` Reason: ${options.reason}`
15556
+ : '';
15557
+ return {
15558
+ groups: [
15559
+ {
15560
+ title: 'chore: combined commit',
15561
+ body: 'Auto-generated single-commit fallback after the split planner could not produce a valid multi-group plan. Edit before applying if you want a more specific message; press `r` to re-roll the planner if a different model might do better.',
15562
+ rationale: `Fallback plan — every staged file in one commit because the LLM could not produce a valid multi-group split.${reasonLine}`,
15563
+ files,
15564
+ hunks: [],
15565
+ },
15566
+ ],
15567
+ };
15568
+ }
15462
15569
  function formatPlanValidationFeedback(issues) {
15463
15570
  const sections = [];
15464
15571
  if (issues.unknownFiles.length) {
@@ -15495,7 +15602,7 @@ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
15495
15602
  * into the same prompt template (`previous_attempt_feedback` slot) so the model can
15496
15603
  * fix its own mistakes without re-running pre-processing.
15497
15604
  */
15498
- async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
15605
+ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, strict = false, }) {
15499
15606
  let lastIssues = null;
15500
15607
  let attempt = 0;
15501
15608
  while (attempt < maxAttempts) {
@@ -15565,9 +15672,42 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
15565
15672
  logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
15566
15673
  }
15567
15674
  }
15568
- throw new Error(lastIssues
15569
- ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${formatPlanValidationIssuesError(lastIssues)}`
15570
- : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
15675
+ const issuesSummary = lastIssues
15676
+ ? formatPlanValidationIssuesError(lastIssues)
15677
+ : 'no captured validator issues';
15678
+ // Strict mode: restore the pre-#1005 behaviour. Callers that pass
15679
+ // `strict: true` (and CLI users via `--strict-split`) want explicit
15680
+ // failure rather than the degraded fallback.
15681
+ if (strict) {
15682
+ throw new Error(lastIssues
15683
+ ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${issuesSummary}`
15684
+ : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
15685
+ }
15686
+ // Default: hand back a trivially-valid single-group fallback. The
15687
+ // caller's apply / preview surface should treat the `fallback` flag
15688
+ // as a signal to nudge the user (it's strictly better than a hard
15689
+ // failure with the staged set still on disk, but it's still a
15690
+ // degraded outcome compared to a real multi-group split).
15691
+ const reason = `LLM exhausted ${maxAttempts} planning attempts; final validator issues: ${issuesSummary}`;
15692
+ if (logger) {
15693
+ logger.verbose(`Plan attempts exhausted — falling back to a single-group plan. ${reason}`, { color: 'yellow' });
15694
+ }
15695
+ return {
15696
+ plan: buildSplitPlanFallback(staged, { reason: issuesSummary }),
15697
+ attempts: maxAttempts,
15698
+ fallback: {
15699
+ reason,
15700
+ lastIssues: lastIssues ?? {
15701
+ unknownFiles: [],
15702
+ duplicateFiles: [],
15703
+ unknownHunks: [],
15704
+ duplicateHunks: [],
15705
+ mixedFiles: [],
15706
+ partiallyCoveredFiles: [],
15707
+ missingFiles: [],
15708
+ },
15709
+ },
15710
+ };
15571
15711
  }
15572
15712
 
15573
15713
  /**
@@ -15769,7 +15909,7 @@ async function applyPatchToIndex$1(patch, git) {
15769
15909
  child.stdin.end();
15770
15910
  });
15771
15911
  }
15772
- async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, }) {
15912
+ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, fallback, }) {
15773
15913
  validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
15774
15914
  assertNoUnstagedOverlap(plan, changes, hunkInventory);
15775
15915
  // Defensive: drop any group with empty files[] AND empty hunks[].
@@ -15872,11 +16012,13 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
15872
16012
  return {
15873
16013
  commitHashes,
15874
16014
  message: `Created ${commitHashes.length} of ${applicableGroups.length} planned commit(s). Failed: ${partial}`,
16015
+ fallback,
15875
16016
  };
15876
16017
  }
15877
16018
  return {
15878
16019
  commitHashes,
15879
16020
  message: `Created ${commitHashes.length} split commit(s).`,
16021
+ fallback,
15880
16022
  };
15881
16023
  }
15882
16024
  /**
@@ -15974,7 +16116,7 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15974
16116
  }
15975
16117
  const resolvedPlanLlm = planLlm ?? llm;
15976
16118
  const resolvedPlanModel = planService?.model ?? config.service.model;
15977
- const { plan } = await generateValidatedCommitSplitPlan({
16119
+ const { plan, fallback } = await generateValidatedCommitSplitPlan({
15978
16120
  llm: resolvedPlanLlm,
15979
16121
  prompt: COMMIT_SPLIT_PROMPT,
15980
16122
  variables: {
@@ -15997,8 +16139,12 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15997
16139
  conventional: useConventional,
15998
16140
  },
15999
16141
  maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
16142
+ // Honour `--strict-split` (CLI) or `strictSplit` (config). When set,
16143
+ // the planner reverts to the pre-#1005 behaviour of throwing on
16144
+ // exhaustion instead of returning the single-group fallback.
16145
+ strict: Boolean(argv.strictSplit ?? config.strictSplit),
16000
16146
  });
16001
- return { plan, context: { changes, hunkInventory } };
16147
+ return { plan, context: { changes, hunkInventory }, fallback };
16002
16148
  }
16003
16149
  async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
16004
16150
  const result = await prepareCommitSplitPlan({
@@ -16014,7 +16160,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
16014
16160
  if ('empty' in result) {
16015
16161
  return 'No staged changes found.';
16016
16162
  }
16017
- const { plan, context } = result;
16163
+ const { plan, context, fallback } = result;
16018
16164
  if (argv.apply) {
16019
16165
  const applied = await applyCommitSplitPlan({
16020
16166
  plan,
@@ -16023,9 +16169,24 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
16023
16169
  git,
16024
16170
  logger,
16025
16171
  noVerify: argv.noVerify || config.noVerify || false,
16172
+ fallback,
16026
16173
  });
16174
+ if (applied.fallback) {
16175
+ return [
16176
+ `Note: applied the single-commit fallback (${applied.fallback.reason}).`,
16177
+ applied.message,
16178
+ ].join('\n');
16179
+ }
16027
16180
  return applied.message;
16028
16181
  }
16182
+ if (fallback) {
16183
+ return [
16184
+ `Note: showing the single-commit fallback plan (${fallback.reason}).`,
16185
+ 'Re-run with a stronger model or use --strict-split to surface the planner error.',
16186
+ '',
16187
+ formatCommitSplitPlan(plan),
16188
+ ].join('\n');
16189
+ }
16029
16190
  return formatCommitSplitPlan(plan);
16030
16191
  }
16031
16192
 
@@ -20059,16 +20220,43 @@ function applyCommitComposeAction(state, action) {
20059
20220
  field: state.field === 'summary' ? 'body' : 'summary',
20060
20221
  };
20061
20222
  case 'setEditing':
20223
+ // Audit finding #12: defensively clear `streamingPreview` when
20224
+ // editing toggles off AND no draft is in flight. The current
20225
+ // input pipeline never triggers this combination, but the
20226
+ // reducer is the source of truth — if a future code path
20227
+ // toggles editing off mid-stream, the preview shouldn't linger
20228
+ // below an idle compose panel.
20062
20229
  return {
20063
20230
  ...state,
20064
20231
  editing: action.value,
20232
+ streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
20065
20233
  };
20066
20234
  case 'setLoading':
20235
+ // Clearing loading also clears any in-flight streaming preview;
20236
+ // the preview's whole purpose is to fill the wait window. Once
20237
+ // the wait ends (success OR failure), the preview is stale.
20067
20238
  return {
20068
20239
  ...state,
20069
20240
  loading: action.value,
20241
+ streamingPreview: action.value ? state.streamingPreview : undefined,
20070
20242
  };
20071
20243
  case 'setDraft':
20244
+ // Audit finding #7: if the user has typed content in summary or
20245
+ // body, the AI draft would silently clobber their work with no
20246
+ // undo. Route the result to `pendingAiDraft` instead and surface
20247
+ // a confirmation message; the user accepts with `R` (replace)
20248
+ // or dismisses with Esc. Empty fields = safe to replace as
20249
+ // before, since there's nothing to lose.
20250
+ if (state.summary.trim() || state.body.trim()) {
20251
+ return {
20252
+ ...state,
20253
+ loading: false,
20254
+ streamingPreview: undefined,
20255
+ pendingAiDraft: action.value,
20256
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20257
+ details: undefined,
20258
+ };
20259
+ }
20072
20260
  // No `message` here — the loader → filled fields are the confirmation
20073
20261
  // that the AI generated something. A lingering "AI draft ready for
20074
20262
  // editing" line in the panel reads as stale state. The runtime still
@@ -20082,6 +20270,8 @@ function applyCommitComposeAction(state, action) {
20082
20270
  loading: false,
20083
20271
  message: undefined,
20084
20272
  details: undefined,
20273
+ streamingPreview: undefined,
20274
+ pendingAiDraft: undefined,
20085
20275
  };
20086
20276
  case 'setResult':
20087
20277
  return {
@@ -20089,6 +20279,57 @@ function applyCommitComposeAction(state, action) {
20089
20279
  loading: false,
20090
20280
  message: action.message,
20091
20281
  details: action.details,
20282
+ streamingPreview: undefined,
20283
+ };
20284
+ case 'setStreamingPreview':
20285
+ // Per-chunk live-preview update. Fires from the streaming
20286
+ // workflow's onChunk callback; the renderer turns it into a
20287
+ // last-N-lines panel below the loading line. Pass `undefined`
20288
+ // to explicitly clear (the workflow does this on completion
20289
+ // alongside the `setDraft` / `setResult` dispatch).
20290
+ return {
20291
+ ...state,
20292
+ streamingPreview: action.value,
20293
+ };
20294
+ case 'setPendingAiDraft':
20295
+ // Audit finding #7: route the AI draft here (instead of straight
20296
+ // to summary/body via `setDraft`) when the user has unsaved
20297
+ // typing the draft would clobber. The dispatcher does the
20298
+ // user-content check; this reducer just stashes the draft and
20299
+ // surfaces a message inviting the user to accept or dismiss.
20300
+ return {
20301
+ ...state,
20302
+ loading: false,
20303
+ streamingPreview: undefined,
20304
+ pendingAiDraft: action.value,
20305
+ message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20306
+ details: undefined,
20307
+ };
20308
+ case 'acceptPendingAiDraft':
20309
+ // Swap the pending draft into the editable fields and clear it.
20310
+ // Mirrors `setDraft`'s field positioning (focus on summary,
20311
+ // editing on) so the user lands in the same place whether they
20312
+ // accepted immediately or after deliberation.
20313
+ if (!state.pendingAiDraft)
20314
+ return state;
20315
+ return {
20316
+ ...state,
20317
+ ...splitCommitDraft(state.pendingAiDraft),
20318
+ field: 'summary',
20319
+ editing: true,
20320
+ loading: false,
20321
+ message: undefined,
20322
+ details: undefined,
20323
+ streamingPreview: undefined,
20324
+ pendingAiDraft: undefined,
20325
+ };
20326
+ case 'dismissPendingAiDraft':
20327
+ // User chose to keep their typing; drop the AI draft.
20328
+ return {
20329
+ ...state,
20330
+ pendingAiDraft: undefined,
20331
+ message: undefined,
20332
+ details: undefined,
20092
20333
  };
20093
20334
  case 'reset':
20094
20335
  // Drop message/details too — the post-commit "Created commit ..."
@@ -20162,6 +20403,233 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
20162
20403
  }
20163
20404
  }
20164
20405
 
20406
+ /**
20407
+ * Same provider / endpoint best-effort extraction `executeChain` uses,
20408
+ * duplicated here rather than imported so the streaming module doesn't
20409
+ * pull on `executeChain`'s implementation. If both helpers ever need to
20410
+ * share more, factor this out to a shared `llmInfo.ts`.
20411
+ */
20412
+ function extractLlmInfo(llm) {
20413
+ const info = {};
20414
+ const className = llm?.constructor?.name || '';
20415
+ if (className.includes('Ollama')) {
20416
+ info.provider = 'ollama';
20417
+ if ('lc_kwargs' in llm && typeof llm.lc_kwargs === 'object' && llm.lc_kwargs !== null) {
20418
+ const kwargs = llm.lc_kwargs;
20419
+ if (typeof kwargs.baseUrl === 'string') {
20420
+ info.endpoint = kwargs.baseUrl;
20421
+ }
20422
+ }
20423
+ }
20424
+ else if (className.includes('OpenAI')) {
20425
+ info.provider = 'openai';
20426
+ }
20427
+ else if (className.includes('Anthropic')) {
20428
+ info.provider = 'anthropic';
20429
+ }
20430
+ return info;
20431
+ }
20432
+ /**
20433
+ * Coerce one streamed chunk into its text fragment. LangChain's
20434
+ * `prompt.pipe(llm).stream(...)` yields `BaseMessageChunk` instances
20435
+ * whose `.content` is sometimes a string and sometimes an array of
20436
+ * content parts (multi-modal models, tool calls). We only care about
20437
+ * the textual delta here; non-text parts are silently dropped because
20438
+ * phase 1's surfaces (stdout + status-line copy) can't render them
20439
+ * anyway.
20440
+ */
20441
+ function coerceChunkText(messageChunk) {
20442
+ if (typeof messageChunk === 'string')
20443
+ return messageChunk;
20444
+ if (messageChunk && typeof messageChunk === 'object' && 'content' in messageChunk) {
20445
+ const content = messageChunk.content;
20446
+ if (typeof content === 'string')
20447
+ return content;
20448
+ if (Array.isArray(content)) {
20449
+ // Multi-part content array — concatenate the text parts only.
20450
+ return content
20451
+ .map((part) => {
20452
+ if (typeof part === 'string')
20453
+ return part;
20454
+ if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
20455
+ return part.text;
20456
+ }
20457
+ return '';
20458
+ })
20459
+ .join('');
20460
+ }
20461
+ }
20462
+ return '';
20463
+ }
20464
+ /**
20465
+ * Streaming variant of `executeChain`. Pipes the prompt into the LLM,
20466
+ * consumes the resulting async iterable, fires `onChunk` with each text
20467
+ * fragment as it arrives, and runs the supplied parser against the
20468
+ * fully-accumulated text on completion. Returns the parsed result.
20469
+ *
20470
+ * Why a separate function instead of an `onChunk?` flag on
20471
+ * `executeChain`? Two reasons:
20472
+ *
20473
+ * 1. The two paths have meaningfully different failure modes — a
20474
+ * half-streamed result can be salvaged with a best-effort parse;
20475
+ * an `invoke()` failure can't. Separate functions let each handle
20476
+ * its own error shape cleanly.
20477
+ * 2. Callers should make an explicit choice about whether they want
20478
+ * streaming. Adding it as an opt-in flag on `executeChain` makes
20479
+ * it tempting to plumb `onChunk` from random surfaces; a separate
20480
+ * helper makes the call site say "yes, this needs streaming."
20481
+ *
20482
+ * No automatic fallback to non-streaming `executeChain` when streaming
20483
+ * fails — by design. Callers that want fallback should `catch` this
20484
+ * function and call `executeChain` themselves. Keeps the helper focused
20485
+ * on the streaming path and the fallback policy explicit at the call
20486
+ * site (different commands may want different fallback strategies).
20487
+ */
20488
+ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk, signal, provider, endpoint, logger, tokenizer, metadata, }) {
20489
+ validateRequired(llm, 'llm', 'executeChainStreaming');
20490
+ validateRequired(prompt, 'prompt', 'executeChainStreaming');
20491
+ validateRequired(variables, 'variables', 'executeChainStreaming');
20492
+ validateRequired(parser, 'parser', 'executeChainStreaming');
20493
+ validateRequired(onChunk, 'onChunk', 'executeChainStreaming');
20494
+ if (typeof variables !== 'object' || Array.isArray(variables)) {
20495
+ throw new LangChainExecutionError('executeChainStreaming: Variables must be a non-array object', { variables, type: typeof variables, isArray: Array.isArray(variables) });
20496
+ }
20497
+ // Pre-flight abort check (#881 phase 3). Callers that ran the cancel
20498
+ // path before reaching here shouldn't pay for prompt rendering or
20499
+ // request setup. Match the contract `chain.stream(..., { signal })`
20500
+ // would have honoured — throw `LangChainCancelledError` rather than
20501
+ // a bare `AbortError`.
20502
+ if (signal?.aborted) {
20503
+ throw new LangChainCancelledError('executeChainStreaming: Aborted before stream opened', '');
20504
+ }
20505
+ const llmInfo = extractLlmInfo(llm);
20506
+ const effectiveProvider = provider || llmInfo.provider;
20507
+ const effectiveEndpoint = endpoint || llmInfo.endpoint;
20508
+ let accumulated = '';
20509
+ try {
20510
+ const renderedPrompt = await prompt.format(variables);
20511
+ const promptTokens = estimatePromptTokens(tokenizer, renderedPrompt);
20512
+ const chain = prompt.pipe(llm);
20513
+ const startedAt = Date.now();
20514
+ // Forward the signal into LangChain's RunnableConfig. The HTTP
20515
+ // transport (openai / anthropic / ollama clients) honours it and
20516
+ // tears down the connection rather than waiting for the model to
20517
+ // finish. The async iterator throws an AbortError that we
20518
+ // classify below.
20519
+ const stream = await chain.stream(variables, signal ? { signal } : undefined);
20520
+ let chunkCount = 0;
20521
+ let callbackFailureCount = 0;
20522
+ // Audit finding #13: cap consecutive callback failures so a
20523
+ // genuinely broken render handler can't tie up the LLM call
20524
+ // silently for the user's entire wait. Five strikes (out of an
20525
+ // expected ~50-500 chunks for a normal commit message) is enough
20526
+ // to ride out a transient blip but small enough to bail before
20527
+ // the user finishes waiting on a useless stream.
20528
+ const MAX_CALLBACK_FAILURES = 5;
20529
+ for await (const messageChunk of stream) {
20530
+ const text = coerceChunkText(messageChunk);
20531
+ if (!text)
20532
+ continue;
20533
+ accumulated += text;
20534
+ chunkCount += 1;
20535
+ try {
20536
+ onChunk({ text, accumulated });
20537
+ // Successful callback resets the consecutive-failure counter —
20538
+ // we only bail on a STREAK of failures, not on isolated ones.
20539
+ callbackFailureCount = 0;
20540
+ }
20541
+ catch (callbackError) {
20542
+ // Deliberately swallow callback errors so a bad render handler
20543
+ // can't tank the entire LLM call. Log at verbose so users with
20544
+ // verbose mode on can still see what happened.
20545
+ callbackFailureCount += 1;
20546
+ logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20547
+ if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
20548
+ logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
20549
+ throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
20550
+ }
20551
+ }
20552
+ }
20553
+ if (!accumulated) {
20554
+ throw new LangChainExecutionError('executeChainStreaming: Stream completed with no text chunks', { variables, promptInputVariables: prompt.inputVariables });
20555
+ }
20556
+ const result = (await parser.invoke(accumulated));
20557
+ const elapsedMs = Date.now() - startedAt;
20558
+ logLlmCall(logger, {
20559
+ task: metadata?.task || 'chain-streaming',
20560
+ provider: effectiveProvider,
20561
+ parserType: parser.constructor.name,
20562
+ variableKeys: Object.keys(variables),
20563
+ promptTokens,
20564
+ elapsedMs,
20565
+ // Surfaced in observability so consumers can spot the streaming
20566
+ // path in their logs without correlating across tools. `chunks`
20567
+ // doubles as a sanity check (a streaming call that delivered 1
20568
+ // chunk is functionally identical to a non-streaming one).
20569
+ streamed: true,
20570
+ streamChunks: chunkCount,
20571
+ ...metadata,
20572
+ });
20573
+ if (result === null || result === undefined) {
20574
+ throw new LangChainExecutionError('executeChainStreaming: Parser returned null or undefined from streamed text', {
20575
+ variables,
20576
+ promptInputVariables: prompt.inputVariables,
20577
+ accumulatedLength: accumulated.length,
20578
+ });
20579
+ }
20580
+ return result;
20581
+ }
20582
+ catch (error) {
20583
+ // Cancellation classifier (#881 phase 3). Three signals: an
20584
+ // explicitly aborted user signal (post-throw check) or a thrown
20585
+ // `AbortError` from the standard DOM API. Either means "user
20586
+ // wanted out," not "the call failed." Wrap the raw error so
20587
+ // callers can pattern-match on `LangChainCancelledError` and
20588
+ // carry the partial accumulated text in case the caller wants
20589
+ // to salvage anything.
20590
+ //
20591
+ // Audit finding #8: an earlier implementation also fell back to
20592
+ // `error.message.includes('aborted')` as a third signal. That
20593
+ // substring heuristic is footgun-shaped — legitimate provider
20594
+ // errors ("model not aborted properly", future API copy) would
20595
+ // misclassify as user cancels. Dropped; rely on the structured
20596
+ // signal (`signal.aborted`) and the standard error class
20597
+ // (`name === 'AbortError'`).
20598
+ const aborted = signal?.aborted ||
20599
+ (error instanceof Error && error.name === 'AbortError');
20600
+ if (aborted) {
20601
+ throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
20602
+ provider: effectiveProvider,
20603
+ endpoint: effectiveEndpoint,
20604
+ });
20605
+ }
20606
+ if (error instanceof LangChainExecutionError ||
20607
+ error instanceof LangChainNetworkError ||
20608
+ error instanceof LangChainCancelledError) {
20609
+ throw error;
20610
+ }
20611
+ if (error instanceof Error && isNetworkError(error)) {
20612
+ throw new LangChainNetworkError(error.message, effectiveEndpoint, effectiveProvider, {
20613
+ originalError: error.name,
20614
+ originalMessage: error.message,
20615
+ stack: error.stack,
20616
+ promptInputVariables: prompt.inputVariables,
20617
+ variableKeys: Object.keys(variables),
20618
+ parserType: parser.constructor.name,
20619
+ streamed: true,
20620
+ });
20621
+ }
20622
+ handleLangChainError(error, 'executeChainStreaming: Stream execution failed', {
20623
+ promptInputVariables: prompt.inputVariables,
20624
+ variableKeys: Object.keys(variables),
20625
+ parserType: parser.constructor.name,
20626
+ provider: effectiveProvider,
20627
+ endpoint: effectiveEndpoint,
20628
+ streamed: true,
20629
+ });
20630
+ }
20631
+ }
20632
+
20165
20633
  const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
20166
20634
 
20167
20635
  REQUIRED JSON FORMAT:
@@ -20186,7 +20654,45 @@ IMPORTANT RULES:
20186
20654
  * are surfaced as `validationErrors`/`warnings` rather than driving an
20187
20655
  * interactive retry flow — the TUI can re-invoke or let the user edit.
20188
20656
  */
20189
- async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), }) {
20657
+ /**
20658
+ * Fallback parser shared between the non-streaming
20659
+ * `executeChainWithSchema` call and the streaming path (#881 phase 2).
20660
+ *
20661
+ * Extracted from the inline `fallbackParser` option so the streaming
20662
+ * path can use the same lossy-but-permissive recovery for accumulated
20663
+ * text. Strips markdown code fences, attempts strict JSON parse, and
20664
+ * falls back to "first line is title, rest is body" when JSON parsing
20665
+ * fails entirely.
20666
+ *
20667
+ * Returned shape always satisfies the schema's structural requirements
20668
+ * (`title` + `body` strings) but the *content* may be the last-ditch
20669
+ * "Auto-generated commit" placeholder. Callers should treat this as a
20670
+ * best-effort salvage, not a parse confirmation.
20671
+ */
20672
+ function salvageCommitMessageFromText(text) {
20673
+ try {
20674
+ let cleanText = text.trim();
20675
+ const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
20676
+ if (codeBlockMatch && codeBlockMatch[1]) {
20677
+ cleanText = codeBlockMatch[1].trim();
20678
+ }
20679
+ const parsed = JSON.parse(cleanText);
20680
+ if (parsed && typeof parsed === 'object' &&
20681
+ typeof parsed.title === 'string' &&
20682
+ typeof parsed.body === 'string' &&
20683
+ parsed.title.length > 0) {
20684
+ return parsed;
20685
+ }
20686
+ }
20687
+ catch {
20688
+ // fall through to line-split salvage
20689
+ }
20690
+ return {
20691
+ title: text.split('\n')[0] || 'Auto-generated commit',
20692
+ body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
20693
+ };
20694
+ }
20695
+ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), onStreamChunk, signal, }) {
20190
20696
  const config = loadConfig(argv);
20191
20697
  const key = getApiKeyForModel(config);
20192
20698
  const { provider } = getModelAndProviderFromConfig(config);
@@ -20329,42 +20835,137 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20329
20835
  tokenizer,
20330
20836
  maxTokens: config.service.tokenLimit || 2048,
20331
20837
  });
20332
- const commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20333
- logger,
20334
- tokenizer,
20335
- metadata: {
20336
- task: useConventional ? 'commit-message-conventional' : 'commit-message',
20337
- command: 'commit-draft',
20338
- provider,
20339
- model: String(model),
20340
- },
20341
- retryOptions: {
20342
- maxAttempts: maxParsingAttempts,
20343
- },
20344
- fallbackParser: (text) => {
20345
- try {
20346
- let cleanText = text.trim();
20347
- const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
20348
- if (codeBlockMatch && codeBlockMatch[1]) {
20349
- cleanText = codeBlockMatch[1].trim();
20350
- }
20351
- const parsed = JSON.parse(cleanText);
20352
- if (parsed && typeof parsed === 'object' &&
20353
- typeof parsed.title === 'string' &&
20354
- typeof parsed.body === 'string' &&
20355
- parsed.title.length > 0) {
20356
- return parsed;
20838
+ // Streaming path (#881 phase 2). Active when the caller supplied
20839
+ // an `onStreamChunk` AND the config opted in. Only the FIRST
20840
+ // attempt streams; the commitlint-retry attempt (attempt === 2)
20841
+ // and the existing executeChainWithSchema retry loop run
20842
+ // non-streaming so we keep the schema-validated retry as the
20843
+ // backstop when the streamed text can't be salvaged.
20844
+ const streamingEnabled = Boolean(onStreamChunk && config.service.streaming?.enabled);
20845
+ const shouldStreamThisAttempt = streamingEnabled && attempt === 1;
20846
+ let commitMsg;
20847
+ if (shouldStreamThisAttempt && onStreamChunk) {
20848
+ // The streaming chain bypasses the schema parser during the
20849
+ // stream itself (no streaming-aware JSON parser today) and
20850
+ // delivers the raw accumulated text to a no-op `parser.invoke`.
20851
+ // We then salvage the structured result via the same lossy
20852
+ // recovery the non-streaming fallbackParser uses. If the
20853
+ // salvager produces a plausible draft, we use it. Otherwise we
20854
+ // fall through to executeChainWithSchema below for a real
20855
+ // schema-validated retry — paying for a second LLM call only
20856
+ // on the edge case where the streamed output is unsalvageable.
20857
+ const streamingParser = createSchemaParser(schema, llm);
20858
+ // Capture the final accumulated text out-of-band so we can
20859
+ // attempt salvage if the parser throws on completion (audit
20860
+ // finding #1). Updated on every chunk; the last value is
20861
+ // whatever the stream produced before the parser ran. Empty
20862
+ // string when streaming throws before any chunks arrived.
20863
+ let streamedAccumulated = '';
20864
+ let salvaged;
20865
+ try {
20866
+ // `executeChainStreaming` runs the parser on the accumulated
20867
+ // text at completion. StructuredOutputParser will throw when
20868
+ // the model produced unparseable JSON — we catch that below
20869
+ // and salvage manually. The happy-path zod-validated object
20870
+ // becomes our commitMsg.
20871
+ commitMsg = await executeChainStreaming({
20872
+ llm,
20873
+ prompt,
20874
+ variables: budgetedPrompt.variables,
20875
+ parser: streamingParser,
20876
+ onChunk: ({ text, accumulated }) => {
20877
+ streamedAccumulated = accumulated;
20878
+ onStreamChunk(text, accumulated);
20879
+ },
20880
+ signal,
20881
+ logger,
20882
+ tokenizer,
20883
+ metadata: {
20884
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20885
+ command: 'commit-draft',
20886
+ provider,
20887
+ model: String(model),
20888
+ },
20889
+ });
20890
+ }
20891
+ catch (streamErr) {
20892
+ // User-initiated cancel (#881 phase 3). Bail out of the
20893
+ // entire attempt loop and let the caller distinguish
20894
+ // "cancelled" from "failed" in the status line. We do NOT
20895
+ // fall through to the non-streaming retry on cancel — the
20896
+ // user explicitly asked to stop, kicking off a fresh
20897
+ // unstreamable LLM call would defy that intent.
20898
+ if (streamErr instanceof LangChainCancelledError) {
20899
+ return {
20900
+ ok: false,
20901
+ draft: streamErr.accumulated || '',
20902
+ warnings,
20903
+ validationErrors: [],
20904
+ cancelled: true,
20905
+ };
20906
+ }
20907
+ // Audit finding #1: try the lossy salvager on the accumulated
20908
+ // text before paying for a second LLM call. The salvager
20909
+ // strips code fences, attempts strict JSON parse, and falls
20910
+ // back to "first line is title, rest is body." We only accept
20911
+ // its output when it produced a real title — the placeholder
20912
+ // title ("Auto-generated commit") means the salvager
20913
+ // couldn't extract anything meaningful and the non-streaming
20914
+ // retry is the better choice.
20915
+ if (streamedAccumulated) {
20916
+ const candidate = salvageCommitMessageFromText(streamedAccumulated);
20917
+ if (candidate.title && candidate.title !== 'Auto-generated commit') {
20918
+ salvaged = candidate;
20919
+ logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
20357
20920
  }
20358
20921
  }
20359
- catch {
20360
- // fall through
20922
+ if (!salvaged) {
20923
+ logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20361
20924
  }
20362
- return {
20363
- title: text.split('\n')[0] || 'Auto-generated commit',
20364
- body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
20365
- };
20366
- },
20367
- });
20925
+ }
20926
+ // Type-narrow: commitMsg is set inside try{}, but TS doesn't
20927
+ // see that across the catch. Re-init through the salvage path
20928
+ // if streaming threw.
20929
+ if (salvaged) {
20930
+ commitMsg = salvaged;
20931
+ }
20932
+ else if (!(commitMsg)) {
20933
+ // Streaming threw AND the salvager couldn't recover anything
20934
+ // useful; fall back to the standard non-streaming flow.
20935
+ // Documented trade-off from the issue: streaming gives us a
20936
+ // preview but the validated result still comes from the
20937
+ // schema-aware retry path when both streaming AND salvage
20938
+ // fail.
20939
+ commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20940
+ logger,
20941
+ tokenizer,
20942
+ metadata: {
20943
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20944
+ command: 'commit-draft',
20945
+ provider,
20946
+ model: String(model),
20947
+ },
20948
+ retryOptions: { maxAttempts: maxParsingAttempts },
20949
+ fallbackParser: salvageCommitMessageFromText,
20950
+ });
20951
+ }
20952
+ }
20953
+ else {
20954
+ commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20955
+ logger,
20956
+ tokenizer,
20957
+ metadata: {
20958
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20959
+ command: 'commit-draft',
20960
+ provider,
20961
+ model: String(model),
20962
+ },
20963
+ retryOptions: {
20964
+ maxAttempts: maxParsingAttempts,
20965
+ },
20966
+ fallbackParser: salvageCommitMessageFromText,
20967
+ });
20968
+ }
20368
20969
  const ticketId = extractTicketIdFromBranchName(branchName);
20369
20970
  const fullMessage = formatCommitMessage(commitMsg, {
20370
20971
  append: argv.append,
@@ -20462,8 +21063,26 @@ async function runCommitDraftWorkflow(input = {}) {
20462
21063
  const argv = createCommitWorkflowArgv('commit');
20463
21064
  const logger = new Logger({ silent: true });
20464
21065
  try {
20465
- const result = await generateCommitDraft({ git, argv, logger });
21066
+ const result = await generateCommitDraft({
21067
+ git,
21068
+ argv,
21069
+ logger,
21070
+ onStreamChunk: input.onStreamChunk,
21071
+ signal: input.signal,
21072
+ });
20466
21073
  const draft = result.draft.trim();
21074
+ // Cancel path (#881 phase 3). Reported separately from success
21075
+ // / failure so the runtime can render a neutral "cancelled"
21076
+ // status line instead of an error.
21077
+ if (result.cancelled) {
21078
+ return {
21079
+ ok: false,
21080
+ message: 'AI draft cancelled.',
21081
+ details: [],
21082
+ draft: '',
21083
+ cancelled: true,
21084
+ };
21085
+ }
20467
21086
  if (result.ok && draft) {
20468
21087
  return {
20469
21088
  ok: true,
@@ -20552,6 +21171,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
20552
21171
  ok: true,
20553
21172
  plan: result.plan,
20554
21173
  planContext: result.context,
21174
+ fallback: result.fallback,
20555
21175
  };
20556
21176
  }
20557
21177
  catch (error) {
@@ -20596,6 +21216,7 @@ async function runCommitSplitApplyWorkflow(input) {
20596
21216
  git,
20597
21217
  logger,
20598
21218
  noVerify: input.noVerify || false,
21219
+ fallback: input.fallback,
20599
21220
  });
20600
21221
  return {
20601
21222
  ok: true,
@@ -20606,6 +21227,7 @@ async function runCommitSplitApplyWorkflow(input) {
20606
21227
  // I/O AND inaccurate when partial-apply landed fewer commits
20607
21228
  // than the plan had groups.
20608
21229
  commitHashes: applied.commitHashes,
21230
+ fallback: applied.fallback,
20609
21231
  };
20610
21232
  }
20611
21233
  catch (error) {
@@ -22750,10 +23372,17 @@ function withPoppedView(state) {
22750
23372
  * in a clean slate — the mental equivalent of a fresh `coco ui`
22751
23373
  * launched against the submodule's working dir.
22752
23374
  *
22753
- * Carry-over preferences (sidebar tab, branch / tag sort, palette
22754
- * recents, inspector tab, diff view mode) are intentionally left
22755
- * untouched. They're user-level choices that should persist across
22756
- * frames, the same way they persist across view pushes today.
23375
+ * Sidebar tab + branch / tag sort are also captured into the return
23376
+ * snapshot (#995) so popping back restores the parent's choices
23377
+ * instead of letting the submodule's tab/sort bleed across the
23378
+ * boundary. The values on the *new* frame are left as-is (carried
23379
+ * over from the parent) — the load effect in app.ts re-reads
23380
+ * persistence keyed on the submodule's workdir and dispatches a
23381
+ * restore if the user has a submodule-specific saved preference.
23382
+ *
23383
+ * Other preferences (palette recents, inspector tab, diff view mode)
23384
+ * stay global by design — the user's preference shouldn't reset when
23385
+ * they cross a submodule boundary.
22757
23386
  *
22758
23387
  * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
22759
23388
  * outside the reducer in `app.ts`'s parallel ref structure — this
@@ -22770,6 +23399,10 @@ function withPushedRepoFrame(state, payload) {
22770
23399
  selectedFileIndex: state.selectedFileIndex,
22771
23400
  selectedSubmoduleIndex: state.selectedSubmoduleIndex,
22772
23401
  filter: state.filter,
23402
+ sidebarTab: state.sidebarTab,
23403
+ userSidebarTab: state.userSidebarTab,
23404
+ branchSort: state.branchSort,
23405
+ tagSort: state.tagSort,
22773
23406
  },
22774
23407
  };
22775
23408
  return {
@@ -22822,6 +23455,15 @@ function withPoppedRepoFrame(state) {
22822
23455
  filter: ret.filter,
22823
23456
  filterMode: false,
22824
23457
  pendingCommitFocused: false,
23458
+ // #995 — restore sidebar tab + sort preferences from the captured
23459
+ // parentReturn. Without this, the submodule's tab / sort choice
23460
+ // bleeds back into the parent after pop: the user picks 'tags' in
23461
+ // a vendored submodule, pops back to the parent, and finds the
23462
+ // parent's previously-selected 'branches' tab quietly replaced.
23463
+ sidebarTab: ret.sidebarTab,
23464
+ userSidebarTab: ret.userSidebarTab,
23465
+ branchSort: ret.branchSort,
23466
+ tagSort: ret.tagSort,
22825
23467
  pendingKey: undefined,
22826
23468
  pendingConfirmationId: undefined,
22827
23469
  pendingConfirmationPayload: undefined,
@@ -23597,6 +24239,17 @@ function applyLogInkAction(state, action) {
23597
24239
  statusLoading: !action.value ? undefined : (action.loading ? true : undefined),
23598
24240
  pendingKey: undefined,
23599
24241
  };
24242
+ case 'setPendingPullRequestBodyDraft':
24243
+ // PR-body draft tracker (#881 phase 4). Set true while
24244
+ // `startCreatePullRequest` is awaiting the changelog-based
24245
+ // body generation; gates the Esc cancel binding in the input
24246
+ // handler so pressing Esc during the wait skips opening the
24247
+ // follow-up prompt instead of falling through to global Esc.
24248
+ return {
24249
+ ...state,
24250
+ pendingPullRequestBodyDraft: action.value || undefined,
24251
+ pendingKey: undefined,
24252
+ };
23600
24253
  case 'setWorkflowAction':
23601
24254
  return {
23602
24255
  ...state,
@@ -23725,10 +24378,14 @@ function applyLogInkAction(state, action) {
23725
24378
  // Cache the result so re-entry (or `c` to PR) reuses it instead of
23726
24379
  // re-running the LLM. Keyed by branch so a checkout naturally
23727
24380
  // produces a fresh generation.
24381
+ // Audit finding #9: `generatedAt` arrives on the action payload
24382
+ // instead of being read from `Date.now()` here, so the reducer
24383
+ // stays pure. Dispatchers (currently `runChangelogView` in
24384
+ // app.ts) call `Date.now()` at dispatch time.
23728
24385
  const cached = {
23729
24386
  text: action.text,
23730
24387
  baseLabel: action.baseLabel,
23731
- generatedAt: Date.now(),
24388
+ generatedAt: action.generatedAt,
23732
24389
  };
23733
24390
  return {
23734
24391
  ...state,
@@ -23782,7 +24439,8 @@ function applyLogInkAction(state, action) {
23782
24439
  // Updated-at timestamp reflects the edit. Not the original
23783
24440
  // generation time — `r` (regenerate) is the explicit knob
23784
24441
  // for "I want fresh LLM output, not my edits".
23785
- generatedAt: Date.now(),
24442
+ // Audit finding #9: timestamp arrives on the action.
24443
+ generatedAt: action.generatedAt,
23786
24444
  },
23787
24445
  },
23788
24446
  pendingKey: undefined,
@@ -23818,7 +24476,9 @@ function applyLogInkAction(state, action) {
23818
24476
  }
23819
24477
  return {
23820
24478
  ...state,
23821
- recentCommitHashes: { hashes: action.hashes, markedAt: Date.now() },
24479
+ // Audit finding #9: timestamp arrives on the action payload
24480
+ // instead of being read from `Date.now()` here.
24481
+ recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
23822
24482
  pendingKey: undefined,
23823
24483
  };
23824
24484
  case 'clearRecentCommits':
@@ -23840,6 +24500,7 @@ function applyLogInkAction(state, action) {
23840
24500
  plan: action.plan,
23841
24501
  planContext: action.planContext,
23842
24502
  scrollOffset: 0,
24503
+ fallback: action.fallback,
23843
24504
  },
23844
24505
  pendingKey: undefined,
23845
24506
  };
@@ -24616,6 +25277,65 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24616
25277
  }
24617
25278
  return [];
24618
25279
  }
25280
+ // Cancel in-flight AI commit draft (#881 phase 3). When the compose
25281
+ // state has a draft in flight (loading === true), Esc aborts the
25282
+ // LLM call and the runtime handler cleans up (clear loading, clear
25283
+ // preview, status line shows "AI draft cancelled.").
25284
+ //
25285
+ // Audit finding #5: the `activeView === 'compose'` gate from the
25286
+ // original phase 3 implementation made the cancel keystroke
25287
+ // unreachable after the user chord-navigated away from compose
25288
+ // mid-stream (Esc would fall through to popView etc., consuming
25289
+ // the navigation intent while the LLM call silently ran to
25290
+ // completion). Cancel should work wherever the user is — they
25291
+ // can always navigate back to compose afterwards.
25292
+ //
25293
+ // Sits above the editing / view handlers so the cancel keystroke
25294
+ // can't fall through to "leave compose" or anything else. Loading
25295
+ // and editing are mutually exclusive in practice (the user can't
25296
+ // type while the AI is generating), but the order here makes the
25297
+ // precedence explicit if that ever changes.
25298
+ if (state.commitCompose.loading && key.escape) {
25299
+ return [{ type: 'cancelAiCommitDraft' }];
25300
+ }
25301
+ // Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
25302
+ // kicks off a changelog-based draft that runs for 5-15 seconds
25303
+ // before the input prompt opens. While the draft is pending, Esc
25304
+ // tells the runtime to skip the prompt and surface a "cancelled"
25305
+ // status. Unlike the compose cancel above, this is a *soft* cancel
25306
+ // — the background LLM call still completes, but its result is
25307
+ // discarded. Acceptable trade-off for now; deeper signal threading
25308
+ // through `changelogHandler` lands in a follow-up if real cancel
25309
+ // becomes a request.
25310
+ //
25311
+ // Sits unconditionally on the global Esc check (no `activeView`
25312
+ // gate) because the draft can be initiated from any view via the
25313
+ // palette `C` binding; Esc must work wherever the user is when
25314
+ // they decide to bail.
25315
+ if (state.pendingPullRequestBodyDraft && key.escape) {
25316
+ return [{ type: 'cancelPullRequestBodyDraft' }];
25317
+ }
25318
+ // Pending AI draft confirmation (audit finding #7). When the AI
25319
+ // draft completes against a non-empty compose surface, it lands in
25320
+ // `pendingAiDraft` instead of overwriting the user's typing. `R`
25321
+ // accepts the swap (user's typing is lost, AI draft becomes the
25322
+ // new content). `Esc` dismisses the AI draft (typing is preserved,
25323
+ // AI draft is lost — the user paid for the tokens but explicitly
25324
+ // chose not to use them).
25325
+ //
25326
+ // Gated on `activeView === 'compose'` because the pending draft is
25327
+ // only meaningful on the compose surface (where the message line
25328
+ // surfaces the prompt). A user who chord-navigated away while the
25329
+ // draft was pending should see the original `R` / Esc semantics of
25330
+ // wherever they are now.
25331
+ if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
25332
+ if (inputValue === 'R' && !key.ctrl && !key.meta) {
25333
+ return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
25334
+ }
25335
+ if (key.escape) {
25336
+ return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
25337
+ }
25338
+ }
24619
25339
  if (state.commitCompose.editing) {
24620
25340
  if (key.escape) {
24621
25341
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -26632,17 +27352,24 @@ function formatRemainingWorktreeHint(unstaged, untracked) {
26632
27352
  *
26633
27353
  * When the worktree is clean post-apply:
26634
27354
  * "Created N commits — press gh to view them in history. Worktree is clean."
27355
+ *
27356
+ * When `fallback` is set, the planner exhausted its retry budget and
27357
+ * the apply landed the single-group fallback plan instead of a real
27358
+ * multi-group split. Prefix the message so the user knows the result
27359
+ * isn't a true LLM split — they may want to re-roll with a different
27360
+ * model, or accept the combined commit as-is.
26635
27361
  */
26636
- function formatSplitApplySuccess(commitCount, unstaged, untracked) {
27362
+ function formatSplitApplySuccess(commitCount, unstaged, untracked, fallback) {
26637
27363
  const created = commitCount === 1
26638
27364
  ? 'Created 1 commit'
26639
27365
  : `Created ${commitCount} commits`;
26640
27366
  const navCue = `${created} — press gh to view them in history.`;
26641
27367
  const remainingHint = formatRemainingWorktreeHint(unstaged, untracked);
26642
- if (!remainingHint) {
26643
- return `${navCue} Worktree is clean.`;
27368
+ const tail = remainingHint ? ` ${remainingHint}` : ' Worktree is clean.';
27369
+ if (fallback) {
27370
+ return `Split planner fallback applied (combined commit) — ${fallback.reason}. ${navCue}${tail}`;
26644
27371
  }
26645
- return `${navCue} ${remainingHint}`;
27372
+ return `${navCue}${tail}`;
26646
27373
  }
26647
27374
 
26648
27375
  /**
@@ -30540,6 +31267,97 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
30540
31267
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
30541
31268
  }
30542
31269
 
31270
+ /**
31271
+ * Streaming-preview helper (#881 phase 2). Turns the raw accumulated
31272
+ * text from an in-flight LLM stream into the last N visual lines that
31273
+ * fit a given panel width, plus a flag telling the renderer whether
31274
+ * earlier content was elided.
31275
+ *
31276
+ * Why a chrome helper instead of inlining the math in the compose
31277
+ * surface: the same shape is going to be reused by PR-body and review
31278
+ * streaming once those surfaces opt in. The visual line math (wrap to
31279
+ * width, count from the bottom, mark truncation) doesn't belong on the
31280
+ * surface itself.
31281
+ *
31282
+ * No JSX / no Ink here — chrome modules stay framework-agnostic and
31283
+ * return data the surface can hand to its own `h(Text, ...)` calls.
31284
+ */
31285
+ /**
31286
+ * Default last-N visible visual lines. Tuned for compose where the
31287
+ * panel already shows summary + body + loading line, so the preview
31288
+ * can't take more vertical space without pushing the state-line off
31289
+ * the bottom of short terminals. 6 lines is roughly two short
31290
+ * commit-body paragraphs — enough to feel like content is flowing,
31291
+ * not so much that the user loses sight of the surrounding chrome.
31292
+ */
31293
+ const DEFAULT_STREAMING_PREVIEW_LINES = 6;
31294
+ /**
31295
+ * Marker prefixed to the first visible line when earlier content was
31296
+ * elided. Chrome theme picks ASCII vs Unicode at render time; this
31297
+ * module returns both so surfaces don't need to import the theme.
31298
+ */
31299
+ const STREAMING_PREVIEW_TRUNCATE_GLYPH = '…';
31300
+ const STREAMING_PREVIEW_TRUNCATE_ASCII = '...';
31301
+ /**
31302
+ * Compute the visible preview window for a streaming buffer.
31303
+ *
31304
+ * The buffer is split on newlines (preserving blank lines so paragraph
31305
+ * spacing stays visible), each source line is hard-wrapped to `width`,
31306
+ * and the trailing `maxLines` wrapped lines are returned. When the
31307
+ * total wrapped line count exceeds `maxLines`, `truncated` is true so
31308
+ * the renderer can prefix the first line with an ellipsis marker.
31309
+ *
31310
+ * Whitespace-only / empty input returns `{ lines: [], truncated: false }`
31311
+ * so renderers can branch on `lines.length === 0` to skip rendering
31312
+ * entirely during the brief window between dispatching `setLoading`
31313
+ * and the first chunk arriving.
31314
+ *
31315
+ * Width math mirrors the compose surface's body wrap (`width - 6` for
31316
+ * border + paddingX + 2-space indent budget); callers pass the width
31317
+ * they intend to use and this helper assumes it's the wrap budget,
31318
+ * not the panel width.
31319
+ */
31320
+ function formatStreamingPreview(accumulated, width, maxLines = DEFAULT_STREAMING_PREVIEW_LINES) {
31321
+ if (!accumulated) {
31322
+ return { lines: [], truncated: false };
31323
+ }
31324
+ const trimmed = accumulated.replace(/\s+$/u, '');
31325
+ if (!trimmed) {
31326
+ return { lines: [], truncated: false };
31327
+ }
31328
+ // Wrap each source line. Empty source lines must survive the wrap so
31329
+ // a stream like "A\n\nB" reads as two paragraphs separated by a blank
31330
+ // row rather than collapsing into "A B".
31331
+ const wrapWidth = Math.max(8, width);
31332
+ const wrapped = [];
31333
+ for (const line of trimmed.split('\n')) {
31334
+ if (line === '') {
31335
+ wrapped.push('');
31336
+ continue;
31337
+ }
31338
+ for (const segment of wrapCells(line, wrapWidth)) {
31339
+ wrapped.push(segment);
31340
+ }
31341
+ }
31342
+ const budget = Math.max(1, maxLines);
31343
+ if (wrapped.length <= budget) {
31344
+ return { lines: wrapped, truncated: false };
31345
+ }
31346
+ return {
31347
+ lines: wrapped.slice(wrapped.length - budget),
31348
+ truncated: true,
31349
+ };
31350
+ }
31351
+ /**
31352
+ * Resolve the truncation marker for the current theme. Pure helper so
31353
+ * the surface can render a single-character glyph in colour terminals
31354
+ * and the ASCII fallback when `theme.ascii` is on. Centralised here so
31355
+ * future surfaces opting into streaming use the same glyph.
31356
+ */
31357
+ function streamingPreviewTruncateMarker(ascii) {
31358
+ return ascii ? STREAMING_PREVIEW_TRUNCATE_ASCII : STREAMING_PREVIEW_TRUNCATE_GLYPH;
31359
+ }
31360
+
30543
31361
  /**
30544
31362
  * Compose surface — the in-TUI commit-message composer. Combines a
30545
31363
  * summary line, a body field, and a state-line footer; an inline
@@ -30549,6 +31367,33 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
30549
31367
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
30550
31368
  * of #890. No behavior change.
30551
31369
  */
31370
+ /**
31371
+ * Render the streaming-preview block — the trailing lines of the
31372
+ * in-flight LLM stream that sit below the loading spinner. Pure
31373
+ * formatting; the wrap math + truncation flag live in the
31374
+ * `streamingPreview` chrome helper so other surfaces (PR body,
31375
+ * review) can reuse them later.
31376
+ *
31377
+ * Returns an empty array when no preview text is present (the loader
31378
+ * just shows the spinner) so the caller's spread doesn't insert blank
31379
+ * rows that would shift the state-line.
31380
+ */
31381
+ function renderStreamingPreviewLines(h, components, preview, width, theme) {
31382
+ const { Text } = components;
31383
+ const view = formatStreamingPreview(preview, width);
31384
+ if (view.lines.length === 0)
31385
+ return [];
31386
+ const marker = view.truncated ? streamingPreviewTruncateMarker(theme.ascii) : '';
31387
+ return view.lines.map((line, index) => {
31388
+ // Prefix the first line with the truncation marker when earlier
31389
+ // content was elided. Subsequent lines render unprefixed.
31390
+ const prefix = index === 0 && marker ? `${marker} ` : ' ';
31391
+ return h(Text, {
31392
+ key: `compose-stream-${index}`,
31393
+ dimColor: true,
31394
+ }, `${prefix}${line}`);
31395
+ });
31396
+ }
30552
31397
  function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
30553
31398
  const { Box, Text } = components;
30554
31399
  const compose = state.commitCompose;
@@ -30572,9 +31417,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
30572
31417
  : ['<empty>'];
30573
31418
  const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
30574
31419
  );
31420
+ // State-line cycles through three modes (#881 phase 3 added the
31421
+ // loading variant): editing copy when the user is typing, cancel
31422
+ // hint when an AI draft is generating, default guidance otherwise.
31423
+ // The cancel hint also covers the streaming preview window — same
31424
+ // keystroke (Esc) aborts whether or not the preview is visible.
30575
31425
  const stateLine = compose.editing
30576
31426
  ? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
30577
- : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
31427
+ : compose.loading
31428
+ ? 'Generating AI draft — press Esc to cancel.'
31429
+ : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
30578
31430
  const hasStagedFiles = (worktree?.files || [])
30579
31431
  .some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
30580
31432
  // Staged file list is rendered in the right Worktree panel
@@ -30621,6 +31473,13 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
30621
31473
  }, theme.ascii
30622
31474
  ? `[${pickSpinnerFrame(spinnerFrame).replace(/[^a-zA-Z0-9 ]/g, '.')}] Generating AI commit draft (this can take a moment)`
30623
31475
  : `${pickSpinnerFrame(spinnerFrame)} Generating AI commit draft… (this can take a moment)`),
31476
+ // Streaming preview (#881 phase 2). Renders the trailing visual
31477
+ // lines of the in-flight LLM stream below the loader so the user
31478
+ // sees content building up instead of an opaque spinner. Empty
31479
+ // before the first chunk arrives; the preview helper returns an
31480
+ // empty `lines` array in that window so we skip the block
31481
+ // entirely.
31482
+ ...renderStreamingPreviewLines(h, components, compose.streamingPreview, bodyTextWidth, theme),
30624
31483
  ]
30625
31484
  : []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncateCells(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
30626
31485
  key: `compose-detail-${index}`,
@@ -35494,9 +36353,18 @@ function LogInkApp(deps) {
35494
36353
  // Wrappers that delegate to the active frame's runtime entry so the
35495
36354
  // existing call sites stay byte-identical. Support both function-
35496
36355
  // updater and value-updater forms (the codebase uses both).
35497
- const setContext = React.useCallback((arg) => {
36356
+ //
36357
+ // `targetDepth` (#994) routes the write to a specific frame instead
36358
+ // of the currently-active one. Loaders that capture the depth at
36359
+ // issue-time and pass it here are robust against frame-stack
36360
+ // mutations (push / pop) that happen while the load is in flight —
36361
+ // the write lands on the frame that issued it, or silently drops
36362
+ // if that frame has been popped (`updateRepoFrameRuntime` no-ops on
36363
+ // out-of-range indices). Without the tag, an in-flight refresh on
36364
+ // the parent would clobber a freshly-pushed submodule frame.
36365
+ const setContext = React.useCallback((arg, targetDepth) => {
35498
36366
  setRuntimes((prev) => {
35499
- const depth = prev.length - 1;
36367
+ const depth = targetDepth ?? prev.length - 1;
35500
36368
  if (depth < 0)
35501
36369
  return prev;
35502
36370
  return updateRepoFrameRuntime(prev, depth, (frame) => ({
@@ -35507,9 +36375,9 @@ function LogInkApp(deps) {
35507
36375
  }));
35508
36376
  });
35509
36377
  }, []);
35510
- const setContextStatus = React.useCallback((arg) => {
36378
+ const setContextStatus = React.useCallback((arg, targetDepth) => {
35511
36379
  setRuntimes((prev) => {
35512
- const depth = prev.length - 1;
36380
+ const depth = targetDepth ?? prev.length - 1;
35513
36381
  if (depth < 0)
35514
36382
  return prev;
35515
36383
  return updateRepoFrameRuntime(prev, depth, (frame) => ({
@@ -35526,6 +36394,14 @@ function LogInkApp(deps) {
35526
36394
  // workdirs for submodule paths recorded in `.gitmodules` (which
35527
36395
  // are repo-relative). Undefined during the brief moment between
35528
36396
  // git swap and the revparse callback resolving.
36397
+ //
36398
+ // Audit finding #10: rapid frame push/pop races are prevented by
36399
+ // the per-effect `cancelled` flag — React fires the cleanup
36400
+ // synchronously BEFORE running the next effect body, so any
36401
+ // pending revparse from the old `git` sees `cancelled === true`
36402
+ // and skips its write. The `git` reference itself is captured by
36403
+ // closure, so each effect run resolves against the right binding.
36404
+ // No additional depth tagging is needed.
35529
36405
  const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
35530
36406
  React.useEffect(() => {
35531
36407
  let cancelled = false;
@@ -35835,28 +36711,39 @@ function LogInkApp(deps) {
35835
36711
  // (stale-while-revalidate) and quietly swap it in once the new fetch
35836
36712
  // resolves — avoids the every-second flicker the watcher would
35837
36713
  // otherwise produce on busy repos.
36714
+ //
36715
+ // #994 — capture the depth this refresh was issued from BEFORE
36716
+ // the await. The callback closure also captured `git` from the
36717
+ // same render, so they're consistent: when the user drills into
36718
+ // a submodule mid-await, the resolved data still lands on the
36719
+ // parent frame (the one whose `git` was used for the fetch),
36720
+ // not on the freshly-pushed submodule frame.
36721
+ const issuedAtDepth = runtimes.length - 1;
35838
36722
  if (!options.silent) {
35839
36723
  dispatch({ type: 'setStatus', value: 'refreshing repository context' });
35840
- setContextStatus(createLogInkContextStatus('loading'));
36724
+ setContextStatus(createLogInkContextStatus('loading'), issuedAtDepth);
35841
36725
  }
35842
36726
  const next = await loadLogInkContext(git);
35843
- setContext(next);
35844
- setContextStatus(createLogInkContextStatus('ready'));
36727
+ setContext(next, issuedAtDepth);
36728
+ setContextStatus(createLogInkContextStatus('ready'), issuedAtDepth);
35845
36729
  if (!options.silent) {
35846
36730
  dispatch({ type: 'setStatus', value: 'repository context refreshed' });
35847
36731
  }
35848
- }, [dispatch, git]);
36732
+ }, [dispatch, git, runtimes.length, setContext, setContextStatus]);
35849
36733
  const refreshWorktreeContext = React.useCallback(async (options = {}) => {
36734
+ // #994 — same frame-tagging as refreshContext above. Worktree
36735
+ // loads are usually fast but still race-prone on slow disks.
36736
+ const issuedAtDepth = runtimes.length - 1;
35850
36737
  if (!options.silent) {
35851
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
36738
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'), issuedAtDepth);
35852
36739
  }
35853
36740
  const worktree = await safe(getWorktreeOverview(git));
35854
36741
  setContext((current) => ({
35855
36742
  ...current,
35856
36743
  worktree,
35857
- }));
35858
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
35859
- }, [git]);
36744
+ }), issuedAtDepth);
36745
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
36746
+ }, [git, runtimes.length, setContext, setContextStatus]);
35860
36747
  // Live refresh: watch .git metadata + the working tree root and reload
35861
36748
  // context when something changes outside the TUI (editor save, external
35862
36749
  // git commands, branch switch in another terminal). Best-effort — the
@@ -35945,18 +36832,50 @@ function LogInkApp(deps) {
35945
36832
  })();
35946
36833
  return () => { cancelled = true; };
35947
36834
  }, [git, dispatch]);
36835
+ // Audit finding #2: re-resolve the repo root inline on every save
36836
+ // and key the deps off `git` + the saved value. The original
36837
+ // implementation read from `repoRootRef.current`, which is async-
36838
+ // populated by the resolver effect above and can lag behind a git
36839
+ // swap. After #995's synchronous pop-restore, the parent's freshly
36840
+ // restored sidebar tab was being written into the submodule's
36841
+ // cache because the ref still held the submodule root during the
36842
+ // brief window before the resolver settled.
36843
+ //
36844
+ // The extra `revparse` cost per save is negligible (saves fire
36845
+ // once per user-initiated tab change, not per render) and the
36846
+ // cancellation flag prevents a stale resolution from racing a
36847
+ // newer one in flight.
35948
36848
  React.useEffect(() => {
35949
- const repoRoot = repoRootRef.current;
35950
- if (!repoRoot)
35951
- return;
35952
- saveSidebarTab(repoRoot, state.userSidebarTab);
35953
- }, [state.userSidebarTab]);
36849
+ let cancelled = false;
36850
+ void (async () => {
36851
+ try {
36852
+ const root = (await git.revparse(['--show-toplevel'])).trim();
36853
+ if (cancelled || !root)
36854
+ return;
36855
+ saveSidebarTab(root, state.userSidebarTab);
36856
+ }
36857
+ catch {
36858
+ // Not in a worktree, or revparse failed — silently skip.
36859
+ // The next save attempt will retry.
36860
+ }
36861
+ })();
36862
+ return () => { cancelled = true; };
36863
+ }, [state.userSidebarTab, git]);
35954
36864
  React.useEffect(() => {
35955
- const repoRoot = repoRootRef.current;
35956
- if (!repoRoot)
35957
- return;
35958
- saveDiffViewMode(repoRoot, state.diffViewMode);
35959
- }, [state.diffViewMode]);
36865
+ let cancelled = false;
36866
+ void (async () => {
36867
+ try {
36868
+ const root = (await git.revparse(['--show-toplevel'])).trim();
36869
+ if (cancelled || !root)
36870
+ return;
36871
+ saveDiffViewMode(root, state.diffViewMode);
36872
+ }
36873
+ catch {
36874
+ // Same as above.
36875
+ }
36876
+ })();
36877
+ return () => { cancelled = true; };
36878
+ }, [state.diffViewMode, git]);
35960
36879
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
35961
36880
  // becomes active with diffSource='stash'. Best-effort — empty stashes
35962
36881
  // or read errors fall through to a "no diff" hint at the render site.
@@ -36081,6 +37000,11 @@ function LogInkApp(deps) {
36081
37000
  const contextStatusRef = React.useRef(contextStatus);
36082
37001
  contextStatusRef.current = contextStatus;
36083
37002
  React.useEffect(() => {
37003
+ // #994 — capture the depth this boot load is being issued for.
37004
+ // The git instance in the closure is bound to this frame; tagged
37005
+ // writes ensure resolved values land on the correct runtime entry
37006
+ // even if a subsequent push/pop changes the active frame mid-load.
37007
+ const issuedAtDepth = runtimes.length - 1;
36084
37008
  let active = true;
36085
37009
  loadLogInkContextEntries(git).forEach(({ key, load }) => {
36086
37010
  if (contextStatusRef.current[key] === 'ready')
@@ -36092,14 +37016,14 @@ function LogInkApp(deps) {
36092
37016
  setContext((current) => ({
36093
37017
  ...current,
36094
37018
  [key]: value,
36095
- }));
36096
- setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'));
37019
+ }), issuedAtDepth);
37020
+ setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'), issuedAtDepth);
36097
37021
  });
36098
37022
  });
36099
37023
  return () => {
36100
37024
  active = false;
36101
37025
  };
36102
- }, [git]);
37026
+ }, [git, runtimes.length, setContext, setContextStatus]);
36103
37027
  // Lazy-load the full pullRequest overview (#808). Only fires when
36104
37028
  // the user actually navigates to the PR view, and only when we
36105
37029
  // don't already have data (so a workflow-triggered refresh that
@@ -36113,21 +37037,22 @@ function LogInkApp(deps) {
36113
37037
  return;
36114
37038
  if (context.pullRequest)
36115
37039
  return;
37040
+ const issuedAtDepth = runtimes.length - 1;
36116
37041
  let active = true;
36117
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
37042
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'), issuedAtDepth);
36118
37043
  void safe(getPullRequestOverview(git)).then((value) => {
36119
37044
  if (!active)
36120
37045
  return;
36121
37046
  setContext((current) => ({
36122
37047
  ...current,
36123
37048
  pullRequest: value,
36124
- }));
36125
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
37049
+ }), issuedAtDepth);
37050
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'), issuedAtDepth);
36126
37051
  });
36127
37052
  return () => {
36128
37053
  active = false;
36129
37054
  };
36130
- }, [git, state.activeView, context.pullRequest]);
37055
+ }, [git, runtimes.length, state.activeView, context.pullRequest, setContext, setContextStatus]);
36131
37056
  // Lazy-load the issue triage list (#882 phase 3, filter-aware
36132
37057
  // since phase 6). Fires on entry to the view AND on filter
36133
37058
  // preset changes (`f` cycles the preset; the dep on
@@ -36139,8 +37064,9 @@ function LogInkApp(deps) {
36139
37064
  return;
36140
37065
  if (context.issueList)
36141
37066
  return;
37067
+ const issuedAtDepth = runtimes.length - 1;
36142
37068
  let active = true;
36143
- setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
37069
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'), issuedAtDepth);
36144
37070
  const filter = issueFilterForPreset(state.selectedIssueFilter);
36145
37071
  void safe(getIssueList(git, filter)).then((value) => {
36146
37072
  if (!active)
@@ -36148,13 +37074,21 @@ function LogInkApp(deps) {
36148
37074
  setContext((current) => ({
36149
37075
  ...current,
36150
37076
  issueList: value,
36151
- }));
36152
- setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
37077
+ }), issuedAtDepth);
37078
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'), issuedAtDepth);
36153
37079
  });
36154
37080
  return () => {
36155
37081
  active = false;
36156
37082
  };
36157
- }, [git, state.activeView, context.issueList, state.selectedIssueFilter]);
37083
+ }, [
37084
+ git,
37085
+ runtimes.length,
37086
+ state.activeView,
37087
+ context.issueList,
37088
+ state.selectedIssueFilter,
37089
+ setContext,
37090
+ setContextStatus,
37091
+ ]);
36158
37092
  // Filter cycling: when the preset changes, drop the cached list
36159
37093
  // so the effect above re-fires with the new filter. Done as a
36160
37094
  // separate effect (rather than folded into the cycle reducer)
@@ -36178,8 +37112,9 @@ function LogInkApp(deps) {
36178
37112
  return;
36179
37113
  if (context.pullRequestList)
36180
37114
  return;
37115
+ const issuedAtDepth = runtimes.length - 1;
36181
37116
  let active = true;
36182
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
37117
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'), issuedAtDepth);
36183
37118
  const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
36184
37119
  void safe(getPullRequestList(git, filter)).then((value) => {
36185
37120
  if (!active)
@@ -36187,13 +37122,21 @@ function LogInkApp(deps) {
36187
37122
  setContext((current) => ({
36188
37123
  ...current,
36189
37124
  pullRequestList: value,
36190
- }));
36191
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
37125
+ }), issuedAtDepth);
37126
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'), issuedAtDepth);
36192
37127
  });
36193
37128
  return () => {
36194
37129
  active = false;
36195
37130
  };
36196
- }, [git, state.activeView, context.pullRequestList, state.selectedPullRequestFilter]);
37131
+ }, [
37132
+ git,
37133
+ runtimes.length,
37134
+ state.activeView,
37135
+ context.pullRequestList,
37136
+ state.selectedPullRequestFilter,
37137
+ setContext,
37138
+ setContextStatus,
37139
+ ]);
36197
37140
  React.useEffect(() => {
36198
37141
  if (state.activeView !== 'pull-request-triage')
36199
37142
  return;
@@ -36223,6 +37166,7 @@ function LogInkApp(deps) {
36223
37166
  return;
36224
37167
  if (context.issueDetailByNumber?.has(cursored.number))
36225
37168
  return;
37169
+ const issuedAtDepth = runtimes.length - 1;
36226
37170
  let active = true;
36227
37171
  const timer = setTimeout(async () => {
36228
37172
  const result = await getIssueDetail(cursored.number);
@@ -36231,17 +37175,19 @@ function LogInkApp(deps) {
36231
37175
  setContext((current) => ({
36232
37176
  ...current,
36233
37177
  issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
36234
- }));
37178
+ }), issuedAtDepth);
36235
37179
  }, DETAIL_HYDRATION_DELAY_MS);
36236
37180
  return () => {
36237
37181
  active = false;
36238
37182
  clearTimeout(timer);
36239
37183
  };
36240
37184
  }, [
37185
+ runtimes.length,
36241
37186
  state.activeView,
36242
37187
  state.selectedIssueIndex,
36243
37188
  filteredIssueList,
36244
37189
  context.issueDetailByNumber,
37190
+ setContext,
36245
37191
  ]);
36246
37192
  React.useEffect(() => {
36247
37193
  if (state.activeView !== 'pull-request-triage')
@@ -36251,6 +37197,7 @@ function LogInkApp(deps) {
36251
37197
  return;
36252
37198
  if (context.pullRequestDetailByNumber?.has(cursored.number))
36253
37199
  return;
37200
+ const issuedAtDepth = runtimes.length - 1;
36254
37201
  let active = true;
36255
37202
  const timer = setTimeout(async () => {
36256
37203
  const result = await getPullRequestDetail(cursored.number);
@@ -36259,17 +37206,19 @@ function LogInkApp(deps) {
36259
37206
  setContext((current) => ({
36260
37207
  ...current,
36261
37208
  pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
36262
- }));
37209
+ }), issuedAtDepth);
36263
37210
  }, DETAIL_HYDRATION_DELAY_MS);
36264
37211
  return () => {
36265
37212
  active = false;
36266
37213
  clearTimeout(timer);
36267
37214
  };
36268
37215
  }, [
37216
+ runtimes.length,
36269
37217
  state.activeView,
36270
37218
  state.selectedPullRequestTriageIndex,
36271
37219
  filteredPullRequestTriageList,
36272
37220
  context.pullRequestDetailByNumber,
37221
+ setContext,
36273
37222
  ]);
36274
37223
  React.useEffect(() => {
36275
37224
  let active = true;
@@ -36530,21 +37479,124 @@ function LogInkApp(deps) {
36530
37479
  state.commitCompose.body,
36531
37480
  state.commitCompose.summary,
36532
37481
  ]);
37482
+ // AbortController for the in-flight AI draft (#881 phase 3). Kept in
37483
+ // a ref rather than state because cancel is a side-effect: the input
37484
+ // handler reads `controllerRef.current?.abort()` synchronously when
37485
+ // Esc fires during a loading draft. Storing it in state would force
37486
+ // a re-render on every set, and React doesn't need to know — only
37487
+ // the imperative cancel path does. Cleared after each call settles
37488
+ // so a stale controller can't cancel a future draft.
37489
+ const aiDraftAbortRef = React.useRef(null);
36533
37490
  const runAiCommitDraft = React.useCallback(async () => {
37491
+ // Tear down any controller from a previous draft (defensive — a
37492
+ // settled call should have cleared it in the finally block, but
37493
+ // double-running would otherwise leave the first orphaned).
37494
+ aiDraftAbortRef.current?.abort();
37495
+ const controller = new AbortController();
37496
+ aiDraftAbortRef.current = controller;
36534
37497
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
36535
37498
  dispatch({ type: 'setStatus', value: 'generating AI commit draft', loading: true });
36536
- const result = await runCommitDraftWorkflow();
36537
- if (result.ok && result.draft) {
36538
- dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
36539
- dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
36540
- return;
37499
+ // Streaming preview (#881 phase 2). The workflow forwards this to
37500
+ // `generateCommitDraft`, which only actually streams when the
37501
+ // user opted in via `service.streaming.enabled`. The callback
37502
+ // updates `commitCompose.streamingPreview` so the compose surface
37503
+ // renders a live last-N-lines preview below the loader. The
37504
+ // reducer clears `streamingPreview` whenever loading flips off
37505
+ // (success or failure), so we don't need an explicit teardown
37506
+ // dispatch here.
37507
+ try {
37508
+ const result = await runCommitDraftWorkflow({
37509
+ git,
37510
+ signal: controller.signal,
37511
+ onStreamChunk: (_text, accumulated) => {
37512
+ // Audit finding #4: skip dispatching into a torn-down
37513
+ // tree. If the user quit (or otherwise unmounted the
37514
+ // workstation) mid-stream, React warns about updates on
37515
+ // an unmounted component. Drop the chunk silently.
37516
+ if (!mountedRef.current)
37517
+ return;
37518
+ // Dispatch the full accumulated text — the preview chrome
37519
+ // helper does the last-N-lines slicing at render time, so
37520
+ // re-doing the slice here would be wasted work. Per-chunk
37521
+ // dispatches are cheap; React batches them and Ink redraws
37522
+ // at its own frame cadence.
37523
+ dispatch({
37524
+ type: 'commitCompose',
37525
+ action: { type: 'setStreamingPreview', value: accumulated },
37526
+ });
37527
+ },
37528
+ });
37529
+ // Audit finding #4 (unmount race): bail out before any
37530
+ // post-await dispatch if the user quit while the LLM call was
37531
+ // in flight. Same pattern as `refreshHistoryRows` upstream.
37532
+ if (!mountedRef.current)
37533
+ return;
37534
+ // Cancel path (#881 phase 3). User pressed Esc during the
37535
+ // stream; reducer drops loading + preview, status line shows
37536
+ // a neutral "cancelled" message. Skip the result / failure
37537
+ // dispatches because the user already knows what happened.
37538
+ if (result.cancelled) {
37539
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37540
+ dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
37541
+ return;
37542
+ }
37543
+ if (result.ok && result.draft) {
37544
+ dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
37545
+ dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
37546
+ return;
37547
+ }
37548
+ dispatch({
37549
+ type: 'commitCompose',
37550
+ action: { type: 'setResult', message: result.message, details: result.details },
37551
+ });
37552
+ dispatch({ type: 'setStatus', value: result.message });
36541
37553
  }
36542
- dispatch({
36543
- type: 'commitCompose',
36544
- action: { type: 'setResult', message: result.message, details: result.details },
36545
- });
36546
- dispatch({ type: 'setStatus', value: result.message });
36547
- }, [dispatch]);
37554
+ catch (error) {
37555
+ // Audit finding #3: defensive recovery for unexpected throws
37556
+ // from the workflow. The workflow catches its own errors
37557
+ // today, so this catch is latent — but any future refactor
37558
+ // that lets an error escape would otherwise strand the
37559
+ // spinner permanently with no user-facing recovery short of
37560
+ // quitting. Surface a generic failure and clear the loading
37561
+ // state so the user can re-try.
37562
+ if (mountedRef.current) {
37563
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37564
+ dispatch({
37565
+ type: 'setStatus',
37566
+ value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
37567
+ kind: 'error',
37568
+ });
37569
+ }
37570
+ }
37571
+ finally {
37572
+ // Clear the ref only if it still points at OUR controller — a
37573
+ // rapid second invocation could have already replaced it, in
37574
+ // which case the new controller is the one that owns cancel
37575
+ // duty now.
37576
+ if (aiDraftAbortRef.current === controller) {
37577
+ aiDraftAbortRef.current = null;
37578
+ }
37579
+ }
37580
+ }, [dispatch, git]);
37581
+ /**
37582
+ * Cancel an in-flight AI draft (#881 phase 3). Called by the input
37583
+ * handler when the user presses Esc while `commitCompose.loading`
37584
+ * is true. Idempotent — calling without an active controller is a
37585
+ * no-op rather than an error so the keystroke handler can fire
37586
+ * unconditionally during the loading window.
37587
+ *
37588
+ * `controller.abort()` propagates through
37589
+ * `executeChainStreaming`, which throws `LangChainCancelledError`,
37590
+ * which becomes `cancelled: true` on the workflow result. The
37591
+ * runAiCommitDraft promise's finally block clears the ref. The
37592
+ * resulting cleanup dispatches (clearing loading + status) happen
37593
+ * back in `runAiCommitDraft`, not here, so this function stays
37594
+ * pure-imperative and the React state updates flow through a
37595
+ * single code path.
37596
+ */
37597
+ const cancelAiCommitDraft = React.useCallback(() => {
37598
+ aiDraftAbortRef.current?.abort();
37599
+ }, []);
36548
37600
  // `C` keystroke handler — start the create-pull-request flow. Resolves
36549
37601
  // the head + base branches from the live context, runs
36550
37602
  // `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
@@ -36558,6 +37610,19 @@ function LogInkApp(deps) {
36558
37610
  // missing) we surface the failure on the status line and skip the
36559
37611
  // prompt entirely — better than opening a prompt the user can't
36560
37612
  // actually submit successfully.
37613
+ // Soft-cancel handle for the PR body draft (#881 phase 4). A mutable
37614
+ // ref rather than state because the cancel decision needs to be
37615
+ // visible synchronously inside the async workflow without forcing
37616
+ // re-renders. Owned by the in-flight invocation: the cancel callback
37617
+ // mutates `.cancelled` on the live ref; the workflow checks it after
37618
+ // `await` resolves and decides whether to open the follow-up prompt.
37619
+ //
37620
+ // The LLM call itself keeps running (no AbortSignal threaded through
37621
+ // `changelogHandler` today). The user-visible outcome — "PR draft
37622
+ // cancelled, no prompt opens" — is identical to a hard cancel, at
37623
+ // the cost of paying for the in-flight tokens. Deeper threading
37624
+ // lands in a follow-up if hard cancel becomes a request.
37625
+ const pullRequestBodyCancelRef = React.useRef(null);
36561
37626
  const startCreatePullRequest = React.useCallback(async () => {
36562
37627
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
36563
37628
  if (!head) {
@@ -36586,32 +37651,78 @@ function LogInkApp(deps) {
36586
37651
  });
36587
37652
  return;
36588
37653
  }
37654
+ // Set up the cancel handle BEFORE flipping the pending flag so a
37655
+ // race between the flag-set and a synchronous Esc keystroke can't
37656
+ // leave the input handler dispatching cancel without a ref to
37657
+ // mutate. The cancel callback no-ops cleanly when the ref is null
37658
+ // (call already settled).
37659
+ const cancelHandle = { cancelled: false };
37660
+ pullRequestBodyCancelRef.current = cancelHandle;
37661
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
37662
+ // Audit finding #6: soft cancel today — Esc skips opening the
37663
+ // follow-up prompt, but the LLM call itself keeps running to
37664
+ // completion (no AbortSignal threaded through the changelog CLI
37665
+ // chain). Status copy reflects that honestly so the user isn't
37666
+ // misled into thinking they're saving tokens.
36589
37667
  dispatch({
36590
37668
  type: 'setStatus',
36591
- value: `generating PR body from changelog (vs ${defaultBranch})…`,
37669
+ value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
36592
37670
  loading: true,
36593
37671
  });
36594
- const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
36595
- // Fallback shape when the changelog generation fails — open the
36596
- // prompt with empty title + body rather than aborting, so the user
36597
- // can still author the PR manually. The status line surfaces why
36598
- // we couldn't pre-fill.
36599
- const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
36600
- const initialBody = body.body || '';
36601
- const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
36602
- if (!body.ok) {
36603
- dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
37672
+ try {
37673
+ const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
37674
+ // Soft-cancel check (#881 phase 4). If the user pressed Esc
37675
+ // while the workflow was awaiting, skip opening the prompt and
37676
+ // surface a neutral status. The underlying LLM call has
37677
+ // already settled its result is discarded. Hard cancel
37678
+ // (aborting the HTTP request mid-flight) is a follow-up.
37679
+ if (cancelHandle.cancelled) {
37680
+ dispatch({ type: 'setStatus', value: 'PR draft cancelled.' });
37681
+ return;
37682
+ }
37683
+ // Fallback shape when the changelog generation fails — open the
37684
+ // prompt with empty title + body rather than aborting, so the user
37685
+ // can still author the PR manually. The status line surfaces why
37686
+ // we couldn't pre-fill.
37687
+ const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
37688
+ const initialBody = body.body || '';
37689
+ const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
37690
+ if (!body.ok) {
37691
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
37692
+ }
37693
+ else {
37694
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
37695
+ }
37696
+ // Audit finding #11: clear the pending flag BEFORE opening the
37697
+ // prompt. If a future refactor adds an `await` between the flag
37698
+ // clear (currently in `finally`) and the `openInputPrompt`
37699
+ // dispatch, an Esc keystroke in the gap would dispatch
37700
+ // `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
37701
+ // the prompt visible with a stale "cancelled" message. Clearing
37702
+ // here moves the flag teardown into the same React batch as the
37703
+ // prompt open, eliminating the race.
37704
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37705
+ dispatch({
37706
+ type: 'openInputPrompt',
37707
+ kind: 'create-pr',
37708
+ label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
37709
+ initial,
37710
+ multiline: true,
37711
+ });
36604
37712
  }
36605
- else {
36606
- dispatch({ type: 'setStatus', value: 'PR body drafted review and Ctrl+D to submit.' });
37713
+ finally {
37714
+ // Belt-and-suspenders: the `try` block clears the flag on the
37715
+ // success path (audit finding #11). This duplicate clear handles
37716
+ // the error / cancel paths where the early-returns skip the
37717
+ // success-path dispatch. Safe to no-op when already false.
37718
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37719
+ // Only clear the ref if we still own it — a second invocation
37720
+ // would have already taken ownership in which case the cancel
37721
+ // duty has rolled over.
37722
+ if (pullRequestBodyCancelRef.current === cancelHandle) {
37723
+ pullRequestBodyCancelRef.current = null;
37724
+ }
36607
37725
  }
36608
- dispatch({
36609
- type: 'openInputPrompt',
36610
- kind: 'create-pr',
36611
- label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
36612
- initial,
36613
- multiline: true,
36614
- });
36615
37726
  }, [
36616
37727
  context.branches?.currentBranch,
36617
37728
  context.provider?.currentBranch,
@@ -36620,6 +37731,24 @@ function LogInkApp(deps) {
36620
37731
  context.pullRequest?.currentPullRequest,
36621
37732
  dispatch,
36622
37733
  ]);
37734
+ /**
37735
+ * Soft-cancel the in-flight PR body draft (#881 phase 4). The
37736
+ * cancel ref's `.cancelled` flag is checked after the workflow's
37737
+ * await resolves; setting it true causes the workflow to skip the
37738
+ * prompt-open and surface a neutral "cancelled" status. The LLM
37739
+ * call itself isn't aborted (no signal threaded through the
37740
+ * `changelogHandler` chain) so the user still pays for the in-flight
37741
+ * tokens. Acceptable for a 5-15s draft; hard cancel lands in a
37742
+ * follow-up if it becomes a real ask.
37743
+ *
37744
+ * Idempotent — calling without an active draft is a no-op.
37745
+ */
37746
+ const cancelPullRequestBodyDraft = React.useCallback(() => {
37747
+ const handle = pullRequestBodyCancelRef.current;
37748
+ if (!handle)
37749
+ return;
37750
+ handle.cancelled = true;
37751
+ }, []);
36623
37752
  // Copy an arbitrary string to the system clipboard. Distinct from
36624
37753
  // `yankFromActiveView` which derives the value from the current view
36625
37754
  // — this one takes the value as an explicit event payload, used by
@@ -36690,6 +37819,11 @@ function LogInkApp(deps) {
36690
37819
  branch: head,
36691
37820
  baseLabel: cached.baseLabel,
36692
37821
  text: cached.text,
37822
+ // Audit finding #9: cache-hit path preserves the original
37823
+ // generation timestamp rather than minting a fresh one — the
37824
+ // "X ago" header should reflect when the LLM ran, not when
37825
+ // the cached entry was re-displayed.
37826
+ generatedAt: cached.generatedAt,
36693
37827
  });
36694
37828
  dispatch({
36695
37829
  type: 'setStatus',
@@ -36718,6 +37852,9 @@ function LogInkApp(deps) {
36718
37852
  branch: head,
36719
37853
  baseLabel,
36720
37854
  text: result.text,
37855
+ // Audit finding #9: timestamp captured at dispatch time, not
37856
+ // inside the reducer.
37857
+ generatedAt: Date.now(),
36721
37858
  });
36722
37859
  dispatch({
36723
37860
  type: 'setStatus',
@@ -36820,7 +37957,7 @@ function LogInkApp(deps) {
36820
37957
  if (editorOk) {
36821
37958
  try {
36822
37959
  const content = readFileSync$1(file, 'utf8');
36823
- dispatch({ type: 'setChangelogText', text: content });
37960
+ dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
36824
37961
  dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
36825
37962
  }
36826
37963
  catch (error) {
@@ -37045,11 +38182,18 @@ function LogInkApp(deps) {
37045
38182
  type: 'setSplitPlanReady',
37046
38183
  plan: result.plan,
37047
38184
  planContext: result.planContext,
38185
+ fallback: result.fallback,
37048
38186
  });
38187
+ const readyMessage = result.fallback
38188
+ ? `Split planner exhausted retries — showing single-commit fallback. y/Enter to apply as one commit, r to re-roll, Esc to cancel.`
38189
+ : `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`;
38190
+ // Use 'info' kind for the fallback path (still actionable, just
38191
+ // not a clean win). The reducer's "warning" is the absence of
38192
+ // `success` framing — the message text itself carries the cue.
37049
38193
  dispatch({
37050
38194
  type: 'setStatus',
37051
- value: `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`,
37052
- kind: 'success',
38195
+ value: readyMessage,
38196
+ kind: result.fallback ? 'info' : 'success',
37053
38197
  });
37054
38198
  }, [context.operation, context.worktree?.stagedCount, dispatch, git]);
37055
38199
  // `y`/Enter inside the overlay — apply the previewed plan. Uses the
@@ -37091,6 +38235,7 @@ function LogInkApp(deps) {
37091
38235
  plan: splitPlan.plan,
37092
38236
  planContext: splitPlan.planContext,
37093
38237
  git,
38238
+ fallback: splitPlan.fallback,
37094
38239
  });
37095
38240
  dump.push(`workflow returned: ok=${result.ok} message="${result.message}" commitHashes=[${(result.commitHashes || []).join(', ')}]`);
37096
38241
  try {
@@ -37167,7 +38312,8 @@ function LogInkApp(deps) {
37167
38312
  // that could disagree with reality on partial-apply.
37168
38313
  const commitHashes = result.commitHashes || [];
37169
38314
  if (commitHashes.length > 0) {
37170
- dispatch({ type: 'markRecentCommits', hashes: commitHashes });
38315
+ // Audit finding #9: timestamp captured at dispatch time.
38316
+ dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
37171
38317
  // DevSkim: ignore DS172411 — function literal, fixed delay,
37172
38318
  // no caller-supplied data flowing through.
37173
38319
  setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
@@ -37185,8 +38331,15 @@ function LogInkApp(deps) {
37185
38331
  });
37186
38332
  return;
37187
38333
  }
37188
- const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
37189
- dispatch({ type: 'setStatus', value: successMessage, kind: 'success' });
38334
+ const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked, result.fallback ? { reason: result.fallback.reason } : undefined);
38335
+ // Fallback path uses 'info' kind apply technically succeeded
38336
+ // but the user should know it landed as a single combined commit
38337
+ // rather than a real LLM-driven multi-group split.
38338
+ dispatch({
38339
+ type: 'setStatus',
38340
+ value: successMessage,
38341
+ kind: result.fallback ? 'info' : 'success',
38342
+ });
37190
38343
  }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
37191
38344
  // Esc inside the overlay — close without applying. Status line gets
37192
38345
  // a confirmation so the user knows the operation was abandoned.
@@ -38646,9 +39799,15 @@ function LogInkApp(deps) {
38646
39799
  else if (event.type === 'runAiCommitDraft') {
38647
39800
  void runAiCommitDraft();
38648
39801
  }
39802
+ else if (event.type === 'cancelAiCommitDraft') {
39803
+ cancelAiCommitDraft();
39804
+ }
38649
39805
  else if (event.type === 'startCreatePullRequest') {
38650
39806
  void startCreatePullRequest();
38651
39807
  }
39808
+ else if (event.type === 'cancelPullRequestBodyDraft') {
39809
+ cancelPullRequestBodyDraft();
39810
+ }
38652
39811
  else if (event.type === 'startChangelogView') {
38653
39812
  void startChangelogView();
38654
39813
  }