git-coco 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.53.0";
81
+ const BUILD_VERSION = "0.54.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -325,6 +325,25 @@ class LangChainNetworkError extends LangChainError {
325
325
  this.provider = provider;
326
326
  }
327
327
  }
328
+ /**
329
+ * User-initiated cancellation (#881 phase 3). Thrown by streaming
330
+ * helpers when an `AbortSignal` they were given fires. Distinct from
331
+ * `LangChainNetworkError` / `LangChainTimeoutError` so callers can
332
+ * pattern-match: a cancelled LLM call is the user's intent, not a
333
+ * failure to surface in the status line as an error.
334
+ *
335
+ * Carries the accumulated text up to the cancel point (when
336
+ * available) so the caller can decide whether to salvage a partial
337
+ * result or discard it. Today the workstation discards — the
338
+ * preview pane was the only consumer of the accumulated text and it
339
+ * gets cleared on cancel anyway.
340
+ */
341
+ class LangChainCancelledError extends LangChainError {
342
+ constructor(message, accumulated, context) {
343
+ super(message, { ...context, accumulated });
344
+ this.accumulated = accumulated;
345
+ }
346
+ }
328
347
 
329
348
  /**
330
349
  * Validates that a required parameter is not null or undefined
@@ -1319,6 +1338,18 @@ const schema$1 = {
1319
1338
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1320
1339
  "default": "balanced"
1321
1340
  },
1341
+ "streaming": {
1342
+ "type": "object",
1343
+ "properties": {
1344
+ "enabled": {
1345
+ "type": "boolean",
1346
+ "description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
1347
+ "default": false
1348
+ }
1349
+ },
1350
+ "additionalProperties": false,
1351
+ "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."
1352
+ },
1322
1353
  "fastPath": {
1323
1354
  "type": "object",
1324
1355
  "properties": {
@@ -1773,6 +1804,18 @@ const schema$1 = {
1773
1804
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1774
1805
  "default": "balanced"
1775
1806
  },
1807
+ "streaming": {
1808
+ "type": "object",
1809
+ "properties": {
1810
+ "enabled": {
1811
+ "type": "boolean",
1812
+ "description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
1813
+ "default": false
1814
+ }
1815
+ },
1816
+ "additionalProperties": false,
1817
+ "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."
1818
+ },
1776
1819
  "fastPath": {
1777
1820
  "type": "object",
1778
1821
  "properties": {
@@ -1967,6 +2010,18 @@ const schema$1 = {
1967
2010
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1968
2011
  "default": "balanced"
1969
2012
  },
2013
+ "streaming": {
2014
+ "type": "object",
2015
+ "properties": {
2016
+ "enabled": {
2017
+ "type": "boolean",
2018
+ "description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
2019
+ "default": false
2020
+ }
2021
+ },
2022
+ "additionalProperties": false,
2023
+ "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."
2024
+ },
1970
2025
  "fastPath": {
1971
2026
  "type": "object",
1972
2027
  "properties": {
@@ -7960,7 +8015,7 @@ async function enforcePromptBudget({ prompt, variables, tokenizer, maxTokens, su
7960
8015
  /**
7961
8016
  * Extracts provider and endpoint info from LLM instance if available
7962
8017
  */
7963
- function extractLlmInfo(llm) {
8018
+ function extractLlmInfo$1(llm) {
7964
8019
  const info = {};
7965
8020
  // Try to extract provider from class name
7966
8021
  const className = llm?.constructor?.name || '';
@@ -8003,7 +8058,7 @@ const executeChain = async ({ llm, prompt, variables, parser, provider, endpoint
8003
8058
  });
8004
8059
  }
8005
8060
  // Extract LLM info for error reporting if not provided
8006
- const llmInfo = extractLlmInfo(llm);
8061
+ const llmInfo = extractLlmInfo$1(llm);
8007
8062
  const effectiveProvider = provider || llmInfo.provider;
8008
8063
  const effectiveEndpoint = endpoint || llmInfo.endpoint;
8009
8064
  try {
@@ -14588,6 +14643,11 @@ const options$8 = {
14588
14643
  type: 'boolean',
14589
14644
  default: false,
14590
14645
  },
14646
+ strictSplit: {
14647
+ description: 'Fail loudly if the split planner exhausts its retry budget with an invalid plan (otherwise falls back to a single combined commit).',
14648
+ type: 'boolean',
14649
+ default: false,
14650
+ },
14591
14651
  };
14592
14652
  const builder$8 = (yargs) => {
14593
14653
  return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
@@ -15476,6 +15536,53 @@ function dropEmptyGroups(plan) {
15476
15536
  }
15477
15537
  return { ...plan, groups: surviving };
15478
15538
  }
15539
+ /**
15540
+ * Construct a trivially-valid single-group plan covering every staged
15541
+ * file. Used as the fallback when the LLM exhausts its retry budget
15542
+ * with an invalid plan — turning a hard failure into a usable
15543
+ * (if degraded) outcome.
15544
+ *
15545
+ * Properties of the returned plan:
15546
+ *
15547
+ * - Exactly one group.
15548
+ * - Every staged file appears in that group's `files[]`. No hunks
15549
+ * are claimed, so any hunk inventory is irrelevant to the plan's
15550
+ * validity.
15551
+ * - By construction: no duplicates, no missing files, no mixed
15552
+ * mode, no phantom hunks. `getPlanValidationIssues` returns an
15553
+ * empty issue set.
15554
+ *
15555
+ * The group's `rationale` carries the reason text the caller wants
15556
+ * to expose to the UI (typically "model exhausted N attempts; last
15557
+ * issues were …"). The `body` carries a short note that survives
15558
+ * into the commit message body so a user who applies without editing
15559
+ * has the context recorded in git history.
15560
+ *
15561
+ * `title` defaults to a generic conventional-commits-compatible
15562
+ * `chore: combined commit` — bland on purpose. Real commit messaging
15563
+ * is the user's job at the compose / apply step.
15564
+ *
15565
+ * The plan is NOT linked to the LLM by construction. If the model
15566
+ * can't produce a valid split, the user still gets one apply-able
15567
+ * commit instead of a thrown error and a still-staged worktree.
15568
+ */
15569
+ function buildSplitPlanFallback(staged, options = {}) {
15570
+ const files = staged.map((change) => change.filePath);
15571
+ const reasonLine = options.reason
15572
+ ? ` Reason: ${options.reason}`
15573
+ : '';
15574
+ return {
15575
+ groups: [
15576
+ {
15577
+ title: 'chore: combined commit',
15578
+ 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.',
15579
+ rationale: `Fallback plan — every staged file in one commit because the LLM could not produce a valid multi-group split.${reasonLine}`,
15580
+ files,
15581
+ hunks: [],
15582
+ },
15583
+ ],
15584
+ };
15585
+ }
15479
15586
  function formatPlanValidationFeedback(issues) {
15480
15587
  const sections = [];
15481
15588
  if (issues.unknownFiles.length) {
@@ -15512,7 +15619,7 @@ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
15512
15619
  * into the same prompt template (`previous_attempt_feedback` slot) so the model can
15513
15620
  * fix its own mistakes without re-running pre-processing.
15514
15621
  */
15515
- async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
15622
+ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, strict = false, }) {
15516
15623
  let lastIssues = null;
15517
15624
  let attempt = 0;
15518
15625
  while (attempt < maxAttempts) {
@@ -15582,9 +15689,42 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
15582
15689
  logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
15583
15690
  }
15584
15691
  }
15585
- throw new Error(lastIssues
15586
- ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${formatPlanValidationIssuesError(lastIssues)}`
15587
- : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
15692
+ const issuesSummary = lastIssues
15693
+ ? formatPlanValidationIssuesError(lastIssues)
15694
+ : 'no captured validator issues';
15695
+ // Strict mode: restore the pre-#1005 behaviour. Callers that pass
15696
+ // `strict: true` (and CLI users via `--strict-split`) want explicit
15697
+ // failure rather than the degraded fallback.
15698
+ if (strict) {
15699
+ throw new Error(lastIssues
15700
+ ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${issuesSummary}`
15701
+ : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
15702
+ }
15703
+ // Default: hand back a trivially-valid single-group fallback. The
15704
+ // caller's apply / preview surface should treat the `fallback` flag
15705
+ // as a signal to nudge the user (it's strictly better than a hard
15706
+ // failure with the staged set still on disk, but it's still a
15707
+ // degraded outcome compared to a real multi-group split).
15708
+ const reason = `LLM exhausted ${maxAttempts} planning attempts; final validator issues: ${issuesSummary}`;
15709
+ if (logger) {
15710
+ logger.verbose(`Plan attempts exhausted — falling back to a single-group plan. ${reason}`, { color: 'yellow' });
15711
+ }
15712
+ return {
15713
+ plan: buildSplitPlanFallback(staged, { reason: issuesSummary }),
15714
+ attempts: maxAttempts,
15715
+ fallback: {
15716
+ reason,
15717
+ lastIssues: lastIssues ?? {
15718
+ unknownFiles: [],
15719
+ duplicateFiles: [],
15720
+ unknownHunks: [],
15721
+ duplicateHunks: [],
15722
+ mixedFiles: [],
15723
+ partiallyCoveredFiles: [],
15724
+ missingFiles: [],
15725
+ },
15726
+ },
15727
+ };
15588
15728
  }
15589
15729
 
15590
15730
  /**
@@ -15786,7 +15926,7 @@ async function applyPatchToIndex$1(patch, git) {
15786
15926
  child.stdin.end();
15787
15927
  });
15788
15928
  }
15789
- async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, }) {
15929
+ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, fallback, }) {
15790
15930
  validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
15791
15931
  assertNoUnstagedOverlap(plan, changes, hunkInventory);
15792
15932
  // Defensive: drop any group with empty files[] AND empty hunks[].
@@ -15889,11 +16029,13 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
15889
16029
  return {
15890
16030
  commitHashes,
15891
16031
  message: `Created ${commitHashes.length} of ${applicableGroups.length} planned commit(s). Failed: ${partial}`,
16032
+ fallback,
15892
16033
  };
15893
16034
  }
15894
16035
  return {
15895
16036
  commitHashes,
15896
16037
  message: `Created ${commitHashes.length} split commit(s).`,
16038
+ fallback,
15897
16039
  };
15898
16040
  }
15899
16041
  /**
@@ -15991,7 +16133,7 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15991
16133
  }
15992
16134
  const resolvedPlanLlm = planLlm ?? llm;
15993
16135
  const resolvedPlanModel = planService?.model ?? config.service.model;
15994
- const { plan } = await generateValidatedCommitSplitPlan({
16136
+ const { plan, fallback } = await generateValidatedCommitSplitPlan({
15995
16137
  llm: resolvedPlanLlm,
15996
16138
  prompt: COMMIT_SPLIT_PROMPT,
15997
16139
  variables: {
@@ -16014,8 +16156,12 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
16014
16156
  conventional: useConventional,
16015
16157
  },
16016
16158
  maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
16159
+ // Honour `--strict-split` (CLI) or `strictSplit` (config). When set,
16160
+ // the planner reverts to the pre-#1005 behaviour of throwing on
16161
+ // exhaustion instead of returning the single-group fallback.
16162
+ strict: Boolean(argv.strictSplit ?? config.strictSplit),
16017
16163
  });
16018
- return { plan, context: { changes, hunkInventory } };
16164
+ return { plan, context: { changes, hunkInventory }, fallback };
16019
16165
  }
16020
16166
  async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
16021
16167
  const result = await prepareCommitSplitPlan({
@@ -16031,7 +16177,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
16031
16177
  if ('empty' in result) {
16032
16178
  return 'No staged changes found.';
16033
16179
  }
16034
- const { plan, context } = result;
16180
+ const { plan, context, fallback } = result;
16035
16181
  if (argv.apply) {
16036
16182
  const applied = await applyCommitSplitPlan({
16037
16183
  plan,
@@ -16040,9 +16186,24 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
16040
16186
  git,
16041
16187
  logger,
16042
16188
  noVerify: argv.noVerify || config.noVerify || false,
16189
+ fallback,
16043
16190
  });
16191
+ if (applied.fallback) {
16192
+ return [
16193
+ `Note: applied the single-commit fallback (${applied.fallback.reason}).`,
16194
+ applied.message,
16195
+ ].join('\n');
16196
+ }
16044
16197
  return applied.message;
16045
16198
  }
16199
+ if (fallback) {
16200
+ return [
16201
+ `Note: showing the single-commit fallback plan (${fallback.reason}).`,
16202
+ 'Re-run with a stronger model or use --strict-split to surface the planner error.',
16203
+ '',
16204
+ formatCommitSplitPlan(plan),
16205
+ ].join('\n');
16206
+ }
16046
16207
  return formatCommitSplitPlan(plan);
16047
16208
  }
16048
16209
 
@@ -20081,9 +20242,13 @@ function applyCommitComposeAction(state, action) {
20081
20242
  editing: action.value,
20082
20243
  };
20083
20244
  case 'setLoading':
20245
+ // Clearing loading also clears any in-flight streaming preview;
20246
+ // the preview's whole purpose is to fill the wait window. Once
20247
+ // the wait ends (success OR failure), the preview is stale.
20084
20248
  return {
20085
20249
  ...state,
20086
20250
  loading: action.value,
20251
+ streamingPreview: action.value ? state.streamingPreview : undefined,
20087
20252
  };
20088
20253
  case 'setDraft':
20089
20254
  // No `message` here — the loader → filled fields are the confirmation
@@ -20099,6 +20264,7 @@ function applyCommitComposeAction(state, action) {
20099
20264
  loading: false,
20100
20265
  message: undefined,
20101
20266
  details: undefined,
20267
+ streamingPreview: undefined,
20102
20268
  };
20103
20269
  case 'setResult':
20104
20270
  return {
@@ -20106,6 +20272,17 @@ function applyCommitComposeAction(state, action) {
20106
20272
  loading: false,
20107
20273
  message: action.message,
20108
20274
  details: action.details,
20275
+ streamingPreview: undefined,
20276
+ };
20277
+ case 'setStreamingPreview':
20278
+ // Per-chunk live-preview update. Fires from the streaming
20279
+ // workflow's onChunk callback; the renderer turns it into a
20280
+ // last-N-lines panel below the loading line. Pass `undefined`
20281
+ // to explicitly clear (the workflow does this on completion
20282
+ // alongside the `setDraft` / `setResult` dispatch).
20283
+ return {
20284
+ ...state,
20285
+ streamingPreview: action.value,
20109
20286
  };
20110
20287
  case 'reset':
20111
20288
  // Drop message/details too — the post-commit "Created commit ..."
@@ -20179,6 +20356,210 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
20179
20356
  }
20180
20357
  }
20181
20358
 
20359
+ /**
20360
+ * Same provider / endpoint best-effort extraction `executeChain` uses,
20361
+ * duplicated here rather than imported so the streaming module doesn't
20362
+ * pull on `executeChain`'s implementation. If both helpers ever need to
20363
+ * share more, factor this out to a shared `llmInfo.ts`.
20364
+ */
20365
+ function extractLlmInfo(llm) {
20366
+ const info = {};
20367
+ const className = llm?.constructor?.name || '';
20368
+ if (className.includes('Ollama')) {
20369
+ info.provider = 'ollama';
20370
+ if ('lc_kwargs' in llm && typeof llm.lc_kwargs === 'object' && llm.lc_kwargs !== null) {
20371
+ const kwargs = llm.lc_kwargs;
20372
+ if (typeof kwargs.baseUrl === 'string') {
20373
+ info.endpoint = kwargs.baseUrl;
20374
+ }
20375
+ }
20376
+ }
20377
+ else if (className.includes('OpenAI')) {
20378
+ info.provider = 'openai';
20379
+ }
20380
+ else if (className.includes('Anthropic')) {
20381
+ info.provider = 'anthropic';
20382
+ }
20383
+ return info;
20384
+ }
20385
+ /**
20386
+ * Coerce one streamed chunk into its text fragment. LangChain's
20387
+ * `prompt.pipe(llm).stream(...)` yields `BaseMessageChunk` instances
20388
+ * whose `.content` is sometimes a string and sometimes an array of
20389
+ * content parts (multi-modal models, tool calls). We only care about
20390
+ * the textual delta here; non-text parts are silently dropped because
20391
+ * phase 1's surfaces (stdout + status-line copy) can't render them
20392
+ * anyway.
20393
+ */
20394
+ function coerceChunkText(messageChunk) {
20395
+ if (typeof messageChunk === 'string')
20396
+ return messageChunk;
20397
+ if (messageChunk && typeof messageChunk === 'object' && 'content' in messageChunk) {
20398
+ const content = messageChunk.content;
20399
+ if (typeof content === 'string')
20400
+ return content;
20401
+ if (Array.isArray(content)) {
20402
+ // Multi-part content array — concatenate the text parts only.
20403
+ return content
20404
+ .map((part) => {
20405
+ if (typeof part === 'string')
20406
+ return part;
20407
+ if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
20408
+ return part.text;
20409
+ }
20410
+ return '';
20411
+ })
20412
+ .join('');
20413
+ }
20414
+ }
20415
+ return '';
20416
+ }
20417
+ /**
20418
+ * Streaming variant of `executeChain`. Pipes the prompt into the LLM,
20419
+ * consumes the resulting async iterable, fires `onChunk` with each text
20420
+ * fragment as it arrives, and runs the supplied parser against the
20421
+ * fully-accumulated text on completion. Returns the parsed result.
20422
+ *
20423
+ * Why a separate function instead of an `onChunk?` flag on
20424
+ * `executeChain`? Two reasons:
20425
+ *
20426
+ * 1. The two paths have meaningfully different failure modes — a
20427
+ * half-streamed result can be salvaged with a best-effort parse;
20428
+ * an `invoke()` failure can't. Separate functions let each handle
20429
+ * its own error shape cleanly.
20430
+ * 2. Callers should make an explicit choice about whether they want
20431
+ * streaming. Adding it as an opt-in flag on `executeChain` makes
20432
+ * it tempting to plumb `onChunk` from random surfaces; a separate
20433
+ * helper makes the call site say "yes, this needs streaming."
20434
+ *
20435
+ * No automatic fallback to non-streaming `executeChain` when streaming
20436
+ * fails — by design. Callers that want fallback should `catch` this
20437
+ * function and call `executeChain` themselves. Keeps the helper focused
20438
+ * on the streaming path and the fallback policy explicit at the call
20439
+ * site (different commands may want different fallback strategies).
20440
+ */
20441
+ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk, signal, provider, endpoint, logger, tokenizer, metadata, }) {
20442
+ validateRequired(llm, 'llm', 'executeChainStreaming');
20443
+ validateRequired(prompt, 'prompt', 'executeChainStreaming');
20444
+ validateRequired(variables, 'variables', 'executeChainStreaming');
20445
+ validateRequired(parser, 'parser', 'executeChainStreaming');
20446
+ validateRequired(onChunk, 'onChunk', 'executeChainStreaming');
20447
+ if (typeof variables !== 'object' || Array.isArray(variables)) {
20448
+ throw new LangChainExecutionError('executeChainStreaming: Variables must be a non-array object', { variables, type: typeof variables, isArray: Array.isArray(variables) });
20449
+ }
20450
+ // Pre-flight abort check (#881 phase 3). Callers that ran the cancel
20451
+ // path before reaching here shouldn't pay for prompt rendering or
20452
+ // request setup. Match the contract `chain.stream(..., { signal })`
20453
+ // would have honoured — throw `LangChainCancelledError` rather than
20454
+ // a bare `AbortError`.
20455
+ if (signal?.aborted) {
20456
+ throw new LangChainCancelledError('executeChainStreaming: Aborted before stream opened', '');
20457
+ }
20458
+ const llmInfo = extractLlmInfo(llm);
20459
+ const effectiveProvider = provider || llmInfo.provider;
20460
+ const effectiveEndpoint = endpoint || llmInfo.endpoint;
20461
+ let accumulated = '';
20462
+ try {
20463
+ const renderedPrompt = await prompt.format(variables);
20464
+ const promptTokens = estimatePromptTokens(tokenizer, renderedPrompt);
20465
+ const chain = prompt.pipe(llm);
20466
+ const startedAt = Date.now();
20467
+ // Forward the signal into LangChain's RunnableConfig. The HTTP
20468
+ // transport (openai / anthropic / ollama clients) honours it and
20469
+ // tears down the connection rather than waiting for the model to
20470
+ // finish. The async iterator throws an AbortError that we
20471
+ // classify below.
20472
+ const stream = await chain.stream(variables, signal ? { signal } : undefined);
20473
+ let chunkCount = 0;
20474
+ for await (const messageChunk of stream) {
20475
+ const text = coerceChunkText(messageChunk);
20476
+ if (!text)
20477
+ continue;
20478
+ accumulated += text;
20479
+ chunkCount += 1;
20480
+ try {
20481
+ onChunk({ text, accumulated });
20482
+ }
20483
+ catch (callbackError) {
20484
+ // Deliberately swallow callback errors so a bad render handler
20485
+ // can't tank the entire LLM call. Log at verbose so users with
20486
+ // verbose mode on can still see what happened.
20487
+ logger?.verbose(`executeChainStreaming: onChunk handler threw: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20488
+ }
20489
+ }
20490
+ if (!accumulated) {
20491
+ throw new LangChainExecutionError('executeChainStreaming: Stream completed with no text chunks', { variables, promptInputVariables: prompt.inputVariables });
20492
+ }
20493
+ const result = (await parser.invoke(accumulated));
20494
+ const elapsedMs = Date.now() - startedAt;
20495
+ logLlmCall(logger, {
20496
+ task: metadata?.task || 'chain-streaming',
20497
+ provider: effectiveProvider,
20498
+ parserType: parser.constructor.name,
20499
+ variableKeys: Object.keys(variables),
20500
+ promptTokens,
20501
+ elapsedMs,
20502
+ // Surfaced in observability so consumers can spot the streaming
20503
+ // path in their logs without correlating across tools. `chunks`
20504
+ // doubles as a sanity check (a streaming call that delivered 1
20505
+ // chunk is functionally identical to a non-streaming one).
20506
+ streamed: true,
20507
+ streamChunks: chunkCount,
20508
+ ...metadata,
20509
+ });
20510
+ if (result === null || result === undefined) {
20511
+ throw new LangChainExecutionError('executeChainStreaming: Parser returned null or undefined from streamed text', {
20512
+ variables,
20513
+ promptInputVariables: prompt.inputVariables,
20514
+ accumulatedLength: accumulated.length,
20515
+ });
20516
+ }
20517
+ return result;
20518
+ }
20519
+ catch (error) {
20520
+ // Cancellation classifier (#881 phase 3). Three signals: an
20521
+ // explicitly aborted user signal (post-throw check), the
20522
+ // standard DOM `AbortError`, or a Node `AbortSignal` with
20523
+ // `signal.aborted === true` while a chain-internal error
20524
+ // propagates. Any of these means "user wanted out," not "the
20525
+ // call failed." Wrap the raw error so callers can pattern-match
20526
+ // on `LangChainCancelledError` and carry the partial accumulated
20527
+ // text in case the caller wants to salvage anything.
20528
+ const aborted = signal?.aborted ||
20529
+ (error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted')));
20530
+ if (aborted) {
20531
+ throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
20532
+ provider: effectiveProvider,
20533
+ endpoint: effectiveEndpoint,
20534
+ });
20535
+ }
20536
+ if (error instanceof LangChainExecutionError ||
20537
+ error instanceof LangChainNetworkError ||
20538
+ error instanceof LangChainCancelledError) {
20539
+ throw error;
20540
+ }
20541
+ if (error instanceof Error && isNetworkError(error)) {
20542
+ throw new LangChainNetworkError(error.message, effectiveEndpoint, effectiveProvider, {
20543
+ originalError: error.name,
20544
+ originalMessage: error.message,
20545
+ stack: error.stack,
20546
+ promptInputVariables: prompt.inputVariables,
20547
+ variableKeys: Object.keys(variables),
20548
+ parserType: parser.constructor.name,
20549
+ streamed: true,
20550
+ });
20551
+ }
20552
+ handleLangChainError(error, 'executeChainStreaming: Stream execution failed', {
20553
+ promptInputVariables: prompt.inputVariables,
20554
+ variableKeys: Object.keys(variables),
20555
+ parserType: parser.constructor.name,
20556
+ provider: effectiveProvider,
20557
+ endpoint: effectiveEndpoint,
20558
+ streamed: true,
20559
+ });
20560
+ }
20561
+ }
20562
+
20182
20563
  const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
20183
20564
 
20184
20565
  REQUIRED JSON FORMAT:
@@ -20203,7 +20584,45 @@ IMPORTANT RULES:
20203
20584
  * are surfaced as `validationErrors`/`warnings` rather than driving an
20204
20585
  * interactive retry flow — the TUI can re-invoke or let the user edit.
20205
20586
  */
20206
- async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), }) {
20587
+ /**
20588
+ * Fallback parser shared between the non-streaming
20589
+ * `executeChainWithSchema` call and the streaming path (#881 phase 2).
20590
+ *
20591
+ * Extracted from the inline `fallbackParser` option so the streaming
20592
+ * path can use the same lossy-but-permissive recovery for accumulated
20593
+ * text. Strips markdown code fences, attempts strict JSON parse, and
20594
+ * falls back to "first line is title, rest is body" when JSON parsing
20595
+ * fails entirely.
20596
+ *
20597
+ * Returned shape always satisfies the schema's structural requirements
20598
+ * (`title` + `body` strings) but the *content* may be the last-ditch
20599
+ * "Auto-generated commit" placeholder. Callers should treat this as a
20600
+ * best-effort salvage, not a parse confirmation.
20601
+ */
20602
+ function salvageCommitMessageFromText(text) {
20603
+ try {
20604
+ let cleanText = text.trim();
20605
+ const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
20606
+ if (codeBlockMatch && codeBlockMatch[1]) {
20607
+ cleanText = codeBlockMatch[1].trim();
20608
+ }
20609
+ const parsed = JSON.parse(cleanText);
20610
+ if (parsed && typeof parsed === 'object' &&
20611
+ typeof parsed.title === 'string' &&
20612
+ typeof parsed.body === 'string' &&
20613
+ parsed.title.length > 0) {
20614
+ return parsed;
20615
+ }
20616
+ }
20617
+ catch {
20618
+ // fall through to line-split salvage
20619
+ }
20620
+ return {
20621
+ title: text.split('\n')[0] || 'Auto-generated commit',
20622
+ body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
20623
+ };
20624
+ }
20625
+ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), onStreamChunk, signal, }) {
20207
20626
  const config = loadConfig(argv);
20208
20627
  const key = getApiKeyForModel(config);
20209
20628
  const { provider } = getModelAndProviderFromConfig(config);
@@ -20346,42 +20765,117 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20346
20765
  tokenizer,
20347
20766
  maxTokens: config.service.tokenLimit || 2048,
20348
20767
  });
20349
- const commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20350
- logger,
20351
- tokenizer,
20352
- metadata: {
20353
- task: useConventional ? 'commit-message-conventional' : 'commit-message',
20354
- command: 'commit-draft',
20355
- provider,
20356
- model: String(model),
20357
- },
20358
- retryOptions: {
20359
- maxAttempts: maxParsingAttempts,
20360
- },
20361
- fallbackParser: (text) => {
20362
- try {
20363
- let cleanText = text.trim();
20364
- const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
20365
- if (codeBlockMatch && codeBlockMatch[1]) {
20366
- cleanText = codeBlockMatch[1].trim();
20367
- }
20368
- const parsed = JSON.parse(cleanText);
20369
- if (parsed && typeof parsed === 'object' &&
20370
- typeof parsed.title === 'string' &&
20371
- typeof parsed.body === 'string' &&
20372
- parsed.title.length > 0) {
20373
- return parsed;
20374
- }
20375
- }
20376
- catch {
20377
- // fall through
20768
+ // Streaming path (#881 phase 2). Active when the caller supplied
20769
+ // an `onStreamChunk` AND the config opted in. Only the FIRST
20770
+ // attempt streams; the commitlint-retry attempt (attempt === 2)
20771
+ // and the existing executeChainWithSchema retry loop run
20772
+ // non-streaming so we keep the schema-validated retry as the
20773
+ // backstop when the streamed text can't be salvaged.
20774
+ const streamingEnabled = Boolean(onStreamChunk && config.service.streaming?.enabled);
20775
+ const shouldStreamThisAttempt = streamingEnabled && attempt === 1;
20776
+ let commitMsg;
20777
+ if (shouldStreamThisAttempt && onStreamChunk) {
20778
+ // The streaming chain bypasses the schema parser during the
20779
+ // stream itself (no streaming-aware JSON parser today) and
20780
+ // delivers the raw accumulated text to a no-op `parser.invoke`.
20781
+ // We then salvage the structured result via the same lossy
20782
+ // recovery the non-streaming fallbackParser uses. If the
20783
+ // salvager produces a plausible draft, we use it. Otherwise we
20784
+ // fall through to executeChainWithSchema below for a real
20785
+ // schema-validated retry — paying for a second LLM call only
20786
+ // on the edge case where the streamed output is unsalvageable.
20787
+ const streamingParser = createSchemaParser(schema, llm);
20788
+ let salvaged;
20789
+ try {
20790
+ // `executeChainStreaming` runs the parser on the accumulated
20791
+ // text at completion. StructuredOutputParser will throw when
20792
+ // the model produced unparseable JSON — we catch that below
20793
+ // and salvage manually. The happy-path zod-validated object
20794
+ // becomes our commitMsg.
20795
+ commitMsg = await executeChainStreaming({
20796
+ llm,
20797
+ prompt,
20798
+ variables: budgetedPrompt.variables,
20799
+ parser: streamingParser,
20800
+ onChunk: ({ text, accumulated }) => {
20801
+ onStreamChunk(text, accumulated);
20802
+ },
20803
+ signal,
20804
+ logger,
20805
+ tokenizer,
20806
+ metadata: {
20807
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20808
+ command: 'commit-draft',
20809
+ provider,
20810
+ model: String(model),
20811
+ },
20812
+ });
20813
+ }
20814
+ catch (streamErr) {
20815
+ // User-initiated cancel (#881 phase 3). Bail out of the
20816
+ // entire attempt loop and let the caller distinguish
20817
+ // "cancelled" from "failed" in the status line. We do NOT
20818
+ // fall through to the non-streaming retry on cancel — the
20819
+ // user explicitly asked to stop, kicking off a fresh
20820
+ // unstreamable LLM call would defy that intent.
20821
+ if (streamErr instanceof LangChainCancelledError) {
20822
+ return {
20823
+ ok: false,
20824
+ draft: streamErr.accumulated || '',
20825
+ warnings,
20826
+ validationErrors: [],
20827
+ cancelled: true,
20828
+ };
20378
20829
  }
20379
- return {
20380
- title: text.split('\n')[0] || 'Auto-generated commit',
20381
- body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
20382
- };
20383
- },
20384
- });
20830
+ // Streamed accumulated text didn't parse cleanly. Try the
20831
+ // lossy salvager on whatever we have; if that produces a
20832
+ // non-placeholder title, accept it. Otherwise fall through
20833
+ // to the non-streaming path which can retry with a fresh
20834
+ // LLM call.
20835
+ logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20836
+ salvaged = undefined;
20837
+ }
20838
+ // Type-narrow: commitMsg is set inside try{}, but TS doesn't
20839
+ // see that across the catch. Re-init through the salvage path
20840
+ // if streaming threw.
20841
+ if (salvaged) {
20842
+ commitMsg = salvaged;
20843
+ }
20844
+ else if (!(commitMsg)) {
20845
+ // Streaming threw; do the standard non-streaming flow to
20846
+ // recover. This is the trade-off documented in the issue —
20847
+ // streaming gives us a preview but the validated result still
20848
+ // comes from the schema-aware retry path when streaming fails.
20849
+ commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20850
+ logger,
20851
+ tokenizer,
20852
+ metadata: {
20853
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20854
+ command: 'commit-draft',
20855
+ provider,
20856
+ model: String(model),
20857
+ },
20858
+ retryOptions: { maxAttempts: maxParsingAttempts },
20859
+ fallbackParser: salvageCommitMessageFromText,
20860
+ });
20861
+ }
20862
+ }
20863
+ else {
20864
+ commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20865
+ logger,
20866
+ tokenizer,
20867
+ metadata: {
20868
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20869
+ command: 'commit-draft',
20870
+ provider,
20871
+ model: String(model),
20872
+ },
20873
+ retryOptions: {
20874
+ maxAttempts: maxParsingAttempts,
20875
+ },
20876
+ fallbackParser: salvageCommitMessageFromText,
20877
+ });
20878
+ }
20385
20879
  const ticketId = extractTicketIdFromBranchName(branchName);
20386
20880
  const fullMessage = formatCommitMessage(commitMsg, {
20387
20881
  append: argv.append,
@@ -20479,8 +20973,26 @@ async function runCommitDraftWorkflow(input = {}) {
20479
20973
  const argv = createCommitWorkflowArgv('commit');
20480
20974
  const logger = new Logger({ silent: true });
20481
20975
  try {
20482
- const result = await generateCommitDraft({ git, argv, logger });
20976
+ const result = await generateCommitDraft({
20977
+ git,
20978
+ argv,
20979
+ logger,
20980
+ onStreamChunk: input.onStreamChunk,
20981
+ signal: input.signal,
20982
+ });
20483
20983
  const draft = result.draft.trim();
20984
+ // Cancel path (#881 phase 3). Reported separately from success
20985
+ // / failure so the runtime can render a neutral "cancelled"
20986
+ // status line instead of an error.
20987
+ if (result.cancelled) {
20988
+ return {
20989
+ ok: false,
20990
+ message: 'AI draft cancelled.',
20991
+ details: [],
20992
+ draft: '',
20993
+ cancelled: true,
20994
+ };
20995
+ }
20484
20996
  if (result.ok && draft) {
20485
20997
  return {
20486
20998
  ok: true,
@@ -20569,6 +21081,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
20569
21081
  ok: true,
20570
21082
  plan: result.plan,
20571
21083
  planContext: result.context,
21084
+ fallback: result.fallback,
20572
21085
  };
20573
21086
  }
20574
21087
  catch (error) {
@@ -20613,6 +21126,7 @@ async function runCommitSplitApplyWorkflow(input) {
20613
21126
  git,
20614
21127
  logger,
20615
21128
  noVerify: input.noVerify || false,
21129
+ fallback: input.fallback,
20616
21130
  });
20617
21131
  return {
20618
21132
  ok: true,
@@ -20623,6 +21137,7 @@ async function runCommitSplitApplyWorkflow(input) {
20623
21137
  // I/O AND inaccurate when partial-apply landed fewer commits
20624
21138
  // than the plan had groups.
20625
21139
  commitHashes: applied.commitHashes,
21140
+ fallback: applied.fallback,
20626
21141
  };
20627
21142
  }
20628
21143
  catch (error) {
@@ -22767,10 +23282,17 @@ function withPoppedView(state) {
22767
23282
  * in a clean slate — the mental equivalent of a fresh `coco ui`
22768
23283
  * launched against the submodule's working dir.
22769
23284
  *
22770
- * Carry-over preferences (sidebar tab, branch / tag sort, palette
22771
- * recents, inspector tab, diff view mode) are intentionally left
22772
- * untouched. They're user-level choices that should persist across
22773
- * frames, the same way they persist across view pushes today.
23285
+ * Sidebar tab + branch / tag sort are also captured into the return
23286
+ * snapshot (#995) so popping back restores the parent's choices
23287
+ * instead of letting the submodule's tab/sort bleed across the
23288
+ * boundary. The values on the *new* frame are left as-is (carried
23289
+ * over from the parent) — the load effect in app.ts re-reads
23290
+ * persistence keyed on the submodule's workdir and dispatches a
23291
+ * restore if the user has a submodule-specific saved preference.
23292
+ *
23293
+ * Other preferences (palette recents, inspector tab, diff view mode)
23294
+ * stay global by design — the user's preference shouldn't reset when
23295
+ * they cross a submodule boundary.
22774
23296
  *
22775
23297
  * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
22776
23298
  * outside the reducer in `app.ts`'s parallel ref structure — this
@@ -22787,6 +23309,10 @@ function withPushedRepoFrame(state, payload) {
22787
23309
  selectedFileIndex: state.selectedFileIndex,
22788
23310
  selectedSubmoduleIndex: state.selectedSubmoduleIndex,
22789
23311
  filter: state.filter,
23312
+ sidebarTab: state.sidebarTab,
23313
+ userSidebarTab: state.userSidebarTab,
23314
+ branchSort: state.branchSort,
23315
+ tagSort: state.tagSort,
22790
23316
  },
22791
23317
  };
22792
23318
  return {
@@ -22839,6 +23365,15 @@ function withPoppedRepoFrame(state) {
22839
23365
  filter: ret.filter,
22840
23366
  filterMode: false,
22841
23367
  pendingCommitFocused: false,
23368
+ // #995 — restore sidebar tab + sort preferences from the captured
23369
+ // parentReturn. Without this, the submodule's tab / sort choice
23370
+ // bleeds back into the parent after pop: the user picks 'tags' in
23371
+ // a vendored submodule, pops back to the parent, and finds the
23372
+ // parent's previously-selected 'branches' tab quietly replaced.
23373
+ sidebarTab: ret.sidebarTab,
23374
+ userSidebarTab: ret.userSidebarTab,
23375
+ branchSort: ret.branchSort,
23376
+ tagSort: ret.tagSort,
22842
23377
  pendingKey: undefined,
22843
23378
  pendingConfirmationId: undefined,
22844
23379
  pendingConfirmationPayload: undefined,
@@ -23614,6 +24149,17 @@ function applyLogInkAction(state, action) {
23614
24149
  statusLoading: !action.value ? undefined : (action.loading ? true : undefined),
23615
24150
  pendingKey: undefined,
23616
24151
  };
24152
+ case 'setPendingPullRequestBodyDraft':
24153
+ // PR-body draft tracker (#881 phase 4). Set true while
24154
+ // `startCreatePullRequest` is awaiting the changelog-based
24155
+ // body generation; gates the Esc cancel binding in the input
24156
+ // handler so pressing Esc during the wait skips opening the
24157
+ // follow-up prompt instead of falling through to global Esc.
24158
+ return {
24159
+ ...state,
24160
+ pendingPullRequestBodyDraft: action.value || undefined,
24161
+ pendingKey: undefined,
24162
+ };
23617
24163
  case 'setWorkflowAction':
23618
24164
  return {
23619
24165
  ...state,
@@ -23857,6 +24403,7 @@ function applyLogInkAction(state, action) {
23857
24403
  plan: action.plan,
23858
24404
  planContext: action.planContext,
23859
24405
  scrollOffset: 0,
24406
+ fallback: action.fallback,
23860
24407
  },
23861
24408
  pendingKey: undefined,
23862
24409
  };
@@ -24633,6 +25180,36 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24633
25180
  }
24634
25181
  return [];
24635
25182
  }
25183
+ // Cancel in-flight AI commit draft (#881 phase 3). When the compose
25184
+ // surface is mid-stream (loading === true), Esc aborts the LLM call
25185
+ // and the runtime handler cleans up (clear loading, clear preview,
25186
+ // status line shows "AI draft cancelled."). Sits above the editing
25187
+ // / view handlers so the cancel keystroke can't fall through to
25188
+ // "leave compose" or anything else.
25189
+ //
25190
+ // Loading and editing are mutually exclusive in practice (the user
25191
+ // can't type while the AI is generating), but the order here makes
25192
+ // the precedence explicit if that ever changes.
25193
+ if (state.activeView === 'compose' && state.commitCompose.loading && key.escape) {
25194
+ return [{ type: 'cancelAiCommitDraft' }];
25195
+ }
25196
+ // Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
25197
+ // kicks off a changelog-based draft that runs for 5-15 seconds
25198
+ // before the input prompt opens. While the draft is pending, Esc
25199
+ // tells the runtime to skip the prompt and surface a "cancelled"
25200
+ // status. Unlike the compose cancel above, this is a *soft* cancel
25201
+ // — the background LLM call still completes, but its result is
25202
+ // discarded. Acceptable trade-off for now; deeper signal threading
25203
+ // through `changelogHandler` lands in a follow-up if real cancel
25204
+ // becomes a request.
25205
+ //
25206
+ // Sits unconditionally on the global Esc check (no `activeView`
25207
+ // gate) because the draft can be initiated from any view via the
25208
+ // palette `C` binding; Esc must work wherever the user is when
25209
+ // they decide to bail.
25210
+ if (state.pendingPullRequestBodyDraft && key.escape) {
25211
+ return [{ type: 'cancelPullRequestBodyDraft' }];
25212
+ }
24636
25213
  if (state.commitCompose.editing) {
24637
25214
  if (key.escape) {
24638
25215
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -26649,17 +27226,24 @@ function formatRemainingWorktreeHint(unstaged, untracked) {
26649
27226
  *
26650
27227
  * When the worktree is clean post-apply:
26651
27228
  * "Created N commits — press gh to view them in history. Worktree is clean."
27229
+ *
27230
+ * When `fallback` is set, the planner exhausted its retry budget and
27231
+ * the apply landed the single-group fallback plan instead of a real
27232
+ * multi-group split. Prefix the message so the user knows the result
27233
+ * isn't a true LLM split — they may want to re-roll with a different
27234
+ * model, or accept the combined commit as-is.
26652
27235
  */
26653
- function formatSplitApplySuccess(commitCount, unstaged, untracked) {
27236
+ function formatSplitApplySuccess(commitCount, unstaged, untracked, fallback) {
26654
27237
  const created = commitCount === 1
26655
27238
  ? 'Created 1 commit'
26656
27239
  : `Created ${commitCount} commits`;
26657
27240
  const navCue = `${created} — press gh to view them in history.`;
26658
27241
  const remainingHint = formatRemainingWorktreeHint(unstaged, untracked);
26659
- if (!remainingHint) {
26660
- return `${navCue} Worktree is clean.`;
27242
+ const tail = remainingHint ? ` ${remainingHint}` : ' Worktree is clean.';
27243
+ if (fallback) {
27244
+ return `Split planner fallback applied (combined commit) — ${fallback.reason}. ${navCue}${tail}`;
26661
27245
  }
26662
- return `${navCue} ${remainingHint}`;
27246
+ return `${navCue}${tail}`;
26663
27247
  }
26664
27248
 
26665
27249
  /**
@@ -30557,6 +31141,97 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
30557
31141
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
30558
31142
  }
30559
31143
 
31144
+ /**
31145
+ * Streaming-preview helper (#881 phase 2). Turns the raw accumulated
31146
+ * text from an in-flight LLM stream into the last N visual lines that
31147
+ * fit a given panel width, plus a flag telling the renderer whether
31148
+ * earlier content was elided.
31149
+ *
31150
+ * Why a chrome helper instead of inlining the math in the compose
31151
+ * surface: the same shape is going to be reused by PR-body and review
31152
+ * streaming once those surfaces opt in. The visual line math (wrap to
31153
+ * width, count from the bottom, mark truncation) doesn't belong on the
31154
+ * surface itself.
31155
+ *
31156
+ * No JSX / no Ink here — chrome modules stay framework-agnostic and
31157
+ * return data the surface can hand to its own `h(Text, ...)` calls.
31158
+ */
31159
+ /**
31160
+ * Default last-N visible visual lines. Tuned for compose where the
31161
+ * panel already shows summary + body + loading line, so the preview
31162
+ * can't take more vertical space without pushing the state-line off
31163
+ * the bottom of short terminals. 6 lines is roughly two short
31164
+ * commit-body paragraphs — enough to feel like content is flowing,
31165
+ * not so much that the user loses sight of the surrounding chrome.
31166
+ */
31167
+ const DEFAULT_STREAMING_PREVIEW_LINES = 6;
31168
+ /**
31169
+ * Marker prefixed to the first visible line when earlier content was
31170
+ * elided. Chrome theme picks ASCII vs Unicode at render time; this
31171
+ * module returns both so surfaces don't need to import the theme.
31172
+ */
31173
+ const STREAMING_PREVIEW_TRUNCATE_GLYPH = '…';
31174
+ const STREAMING_PREVIEW_TRUNCATE_ASCII = '...';
31175
+ /**
31176
+ * Compute the visible preview window for a streaming buffer.
31177
+ *
31178
+ * The buffer is split on newlines (preserving blank lines so paragraph
31179
+ * spacing stays visible), each source line is hard-wrapped to `width`,
31180
+ * and the trailing `maxLines` wrapped lines are returned. When the
31181
+ * total wrapped line count exceeds `maxLines`, `truncated` is true so
31182
+ * the renderer can prefix the first line with an ellipsis marker.
31183
+ *
31184
+ * Whitespace-only / empty input returns `{ lines: [], truncated: false }`
31185
+ * so renderers can branch on `lines.length === 0` to skip rendering
31186
+ * entirely during the brief window between dispatching `setLoading`
31187
+ * and the first chunk arriving.
31188
+ *
31189
+ * Width math mirrors the compose surface's body wrap (`width - 6` for
31190
+ * border + paddingX + 2-space indent budget); callers pass the width
31191
+ * they intend to use and this helper assumes it's the wrap budget,
31192
+ * not the panel width.
31193
+ */
31194
+ function formatStreamingPreview(accumulated, width, maxLines = DEFAULT_STREAMING_PREVIEW_LINES) {
31195
+ if (!accumulated) {
31196
+ return { lines: [], truncated: false };
31197
+ }
31198
+ const trimmed = accumulated.replace(/\s+$/u, '');
31199
+ if (!trimmed) {
31200
+ return { lines: [], truncated: false };
31201
+ }
31202
+ // Wrap each source line. Empty source lines must survive the wrap so
31203
+ // a stream like "A\n\nB" reads as two paragraphs separated by a blank
31204
+ // row rather than collapsing into "A B".
31205
+ const wrapWidth = Math.max(8, width);
31206
+ const wrapped = [];
31207
+ for (const line of trimmed.split('\n')) {
31208
+ if (line === '') {
31209
+ wrapped.push('');
31210
+ continue;
31211
+ }
31212
+ for (const segment of wrapCells(line, wrapWidth)) {
31213
+ wrapped.push(segment);
31214
+ }
31215
+ }
31216
+ const budget = Math.max(1, maxLines);
31217
+ if (wrapped.length <= budget) {
31218
+ return { lines: wrapped, truncated: false };
31219
+ }
31220
+ return {
31221
+ lines: wrapped.slice(wrapped.length - budget),
31222
+ truncated: true,
31223
+ };
31224
+ }
31225
+ /**
31226
+ * Resolve the truncation marker for the current theme. Pure helper so
31227
+ * the surface can render a single-character glyph in colour terminals
31228
+ * and the ASCII fallback when `theme.ascii` is on. Centralised here so
31229
+ * future surfaces opting into streaming use the same glyph.
31230
+ */
31231
+ function streamingPreviewTruncateMarker(ascii) {
31232
+ return ascii ? STREAMING_PREVIEW_TRUNCATE_ASCII : STREAMING_PREVIEW_TRUNCATE_GLYPH;
31233
+ }
31234
+
30560
31235
  /**
30561
31236
  * Compose surface — the in-TUI commit-message composer. Combines a
30562
31237
  * summary line, a body field, and a state-line footer; an inline
@@ -30566,6 +31241,33 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
30566
31241
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
30567
31242
  * of #890. No behavior change.
30568
31243
  */
31244
+ /**
31245
+ * Render the streaming-preview block — the trailing lines of the
31246
+ * in-flight LLM stream that sit below the loading spinner. Pure
31247
+ * formatting; the wrap math + truncation flag live in the
31248
+ * `streamingPreview` chrome helper so other surfaces (PR body,
31249
+ * review) can reuse them later.
31250
+ *
31251
+ * Returns an empty array when no preview text is present (the loader
31252
+ * just shows the spinner) so the caller's spread doesn't insert blank
31253
+ * rows that would shift the state-line.
31254
+ */
31255
+ function renderStreamingPreviewLines(h, components, preview, width, theme) {
31256
+ const { Text } = components;
31257
+ const view = formatStreamingPreview(preview, width);
31258
+ if (view.lines.length === 0)
31259
+ return [];
31260
+ const marker = view.truncated ? streamingPreviewTruncateMarker(theme.ascii) : '';
31261
+ return view.lines.map((line, index) => {
31262
+ // Prefix the first line with the truncation marker when earlier
31263
+ // content was elided. Subsequent lines render unprefixed.
31264
+ const prefix = index === 0 && marker ? `${marker} ` : ' ';
31265
+ return h(Text, {
31266
+ key: `compose-stream-${index}`,
31267
+ dimColor: true,
31268
+ }, `${prefix}${line}`);
31269
+ });
31270
+ }
30569
31271
  function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
30570
31272
  const { Box, Text } = components;
30571
31273
  const compose = state.commitCompose;
@@ -30589,9 +31291,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
30589
31291
  : ['<empty>'];
30590
31292
  const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
30591
31293
  );
31294
+ // State-line cycles through three modes (#881 phase 3 added the
31295
+ // loading variant): editing copy when the user is typing, cancel
31296
+ // hint when an AI draft is generating, default guidance otherwise.
31297
+ // The cancel hint also covers the streaming preview window — same
31298
+ // keystroke (Esc) aborts whether or not the preview is visible.
30592
31299
  const stateLine = compose.editing
30593
31300
  ? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
30594
- : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
31301
+ : compose.loading
31302
+ ? 'Generating AI draft — press Esc to cancel.'
31303
+ : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
30595
31304
  const hasStagedFiles = (worktree?.files || [])
30596
31305
  .some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
30597
31306
  // Staged file list is rendered in the right Worktree panel
@@ -30638,6 +31347,13 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
30638
31347
  }, theme.ascii
30639
31348
  ? `[${pickSpinnerFrame(spinnerFrame).replace(/[^a-zA-Z0-9 ]/g, '.')}] Generating AI commit draft (this can take a moment)`
30640
31349
  : `${pickSpinnerFrame(spinnerFrame)} Generating AI commit draft… (this can take a moment)`),
31350
+ // Streaming preview (#881 phase 2). Renders the trailing visual
31351
+ // lines of the in-flight LLM stream below the loader so the user
31352
+ // sees content building up instead of an opaque spinner. Empty
31353
+ // before the first chunk arrives; the preview helper returns an
31354
+ // empty `lines` array in that window so we skip the block
31355
+ // entirely.
31356
+ ...renderStreamingPreviewLines(h, components, compose.streamingPreview, bodyTextWidth, theme),
30641
31357
  ]
30642
31358
  : []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncateCells(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
30643
31359
  key: `compose-detail-${index}`,
@@ -35511,9 +36227,18 @@ function LogInkApp(deps) {
35511
36227
  // Wrappers that delegate to the active frame's runtime entry so the
35512
36228
  // existing call sites stay byte-identical. Support both function-
35513
36229
  // updater and value-updater forms (the codebase uses both).
35514
- const setContext = React.useCallback((arg) => {
36230
+ //
36231
+ // `targetDepth` (#994) routes the write to a specific frame instead
36232
+ // of the currently-active one. Loaders that capture the depth at
36233
+ // issue-time and pass it here are robust against frame-stack
36234
+ // mutations (push / pop) that happen while the load is in flight —
36235
+ // the write lands on the frame that issued it, or silently drops
36236
+ // if that frame has been popped (`updateRepoFrameRuntime` no-ops on
36237
+ // out-of-range indices). Without the tag, an in-flight refresh on
36238
+ // the parent would clobber a freshly-pushed submodule frame.
36239
+ const setContext = React.useCallback((arg, targetDepth) => {
35515
36240
  setRuntimes((prev) => {
35516
- const depth = prev.length - 1;
36241
+ const depth = targetDepth ?? prev.length - 1;
35517
36242
  if (depth < 0)
35518
36243
  return prev;
35519
36244
  return updateRepoFrameRuntime(prev, depth, (frame) => ({
@@ -35524,9 +36249,9 @@ function LogInkApp(deps) {
35524
36249
  }));
35525
36250
  });
35526
36251
  }, []);
35527
- const setContextStatus = React.useCallback((arg) => {
36252
+ const setContextStatus = React.useCallback((arg, targetDepth) => {
35528
36253
  setRuntimes((prev) => {
35529
- const depth = prev.length - 1;
36254
+ const depth = targetDepth ?? prev.length - 1;
35530
36255
  if (depth < 0)
35531
36256
  return prev;
35532
36257
  return updateRepoFrameRuntime(prev, depth, (frame) => ({
@@ -35852,28 +36577,39 @@ function LogInkApp(deps) {
35852
36577
  // (stale-while-revalidate) and quietly swap it in once the new fetch
35853
36578
  // resolves — avoids the every-second flicker the watcher would
35854
36579
  // otherwise produce on busy repos.
36580
+ //
36581
+ // #994 — capture the depth this refresh was issued from BEFORE
36582
+ // the await. The callback closure also captured `git` from the
36583
+ // same render, so they're consistent: when the user drills into
36584
+ // a submodule mid-await, the resolved data still lands on the
36585
+ // parent frame (the one whose `git` was used for the fetch),
36586
+ // not on the freshly-pushed submodule frame.
36587
+ const issuedAtDepth = runtimes.length - 1;
35855
36588
  if (!options.silent) {
35856
36589
  dispatch({ type: 'setStatus', value: 'refreshing repository context' });
35857
- setContextStatus(createLogInkContextStatus('loading'));
36590
+ setContextStatus(createLogInkContextStatus('loading'), issuedAtDepth);
35858
36591
  }
35859
36592
  const next = await loadLogInkContext(git);
35860
- setContext(next);
35861
- setContextStatus(createLogInkContextStatus('ready'));
36593
+ setContext(next, issuedAtDepth);
36594
+ setContextStatus(createLogInkContextStatus('ready'), issuedAtDepth);
35862
36595
  if (!options.silent) {
35863
36596
  dispatch({ type: 'setStatus', value: 'repository context refreshed' });
35864
36597
  }
35865
- }, [dispatch, git]);
36598
+ }, [dispatch, git, runtimes.length, setContext, setContextStatus]);
35866
36599
  const refreshWorktreeContext = React.useCallback(async (options = {}) => {
36600
+ // #994 — same frame-tagging as refreshContext above. Worktree
36601
+ // loads are usually fast but still race-prone on slow disks.
36602
+ const issuedAtDepth = runtimes.length - 1;
35867
36603
  if (!options.silent) {
35868
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
36604
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'), issuedAtDepth);
35869
36605
  }
35870
36606
  const worktree = await safe(getWorktreeOverview(git));
35871
36607
  setContext((current) => ({
35872
36608
  ...current,
35873
36609
  worktree,
35874
- }));
35875
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
35876
- }, [git]);
36610
+ }), issuedAtDepth);
36611
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
36612
+ }, [git, runtimes.length, setContext, setContextStatus]);
35877
36613
  // Live refresh: watch .git metadata + the working tree root and reload
35878
36614
  // context when something changes outside the TUI (editor save, external
35879
36615
  // git commands, branch switch in another terminal). Best-effort — the
@@ -36098,6 +36834,11 @@ function LogInkApp(deps) {
36098
36834
  const contextStatusRef = React.useRef(contextStatus);
36099
36835
  contextStatusRef.current = contextStatus;
36100
36836
  React.useEffect(() => {
36837
+ // #994 — capture the depth this boot load is being issued for.
36838
+ // The git instance in the closure is bound to this frame; tagged
36839
+ // writes ensure resolved values land on the correct runtime entry
36840
+ // even if a subsequent push/pop changes the active frame mid-load.
36841
+ const issuedAtDepth = runtimes.length - 1;
36101
36842
  let active = true;
36102
36843
  loadLogInkContextEntries(git).forEach(({ key, load }) => {
36103
36844
  if (contextStatusRef.current[key] === 'ready')
@@ -36109,14 +36850,14 @@ function LogInkApp(deps) {
36109
36850
  setContext((current) => ({
36110
36851
  ...current,
36111
36852
  [key]: value,
36112
- }));
36113
- setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'));
36853
+ }), issuedAtDepth);
36854
+ setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'), issuedAtDepth);
36114
36855
  });
36115
36856
  });
36116
36857
  return () => {
36117
36858
  active = false;
36118
36859
  };
36119
- }, [git]);
36860
+ }, [git, runtimes.length, setContext, setContextStatus]);
36120
36861
  // Lazy-load the full pullRequest overview (#808). Only fires when
36121
36862
  // the user actually navigates to the PR view, and only when we
36122
36863
  // don't already have data (so a workflow-triggered refresh that
@@ -36130,21 +36871,22 @@ function LogInkApp(deps) {
36130
36871
  return;
36131
36872
  if (context.pullRequest)
36132
36873
  return;
36874
+ const issuedAtDepth = runtimes.length - 1;
36133
36875
  let active = true;
36134
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
36876
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'), issuedAtDepth);
36135
36877
  void safe(getPullRequestOverview(git)).then((value) => {
36136
36878
  if (!active)
36137
36879
  return;
36138
36880
  setContext((current) => ({
36139
36881
  ...current,
36140
36882
  pullRequest: value,
36141
- }));
36142
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
36883
+ }), issuedAtDepth);
36884
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'), issuedAtDepth);
36143
36885
  });
36144
36886
  return () => {
36145
36887
  active = false;
36146
36888
  };
36147
- }, [git, state.activeView, context.pullRequest]);
36889
+ }, [git, runtimes.length, state.activeView, context.pullRequest, setContext, setContextStatus]);
36148
36890
  // Lazy-load the issue triage list (#882 phase 3, filter-aware
36149
36891
  // since phase 6). Fires on entry to the view AND on filter
36150
36892
  // preset changes (`f` cycles the preset; the dep on
@@ -36156,8 +36898,9 @@ function LogInkApp(deps) {
36156
36898
  return;
36157
36899
  if (context.issueList)
36158
36900
  return;
36901
+ const issuedAtDepth = runtimes.length - 1;
36159
36902
  let active = true;
36160
- setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
36903
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'), issuedAtDepth);
36161
36904
  const filter = issueFilterForPreset(state.selectedIssueFilter);
36162
36905
  void safe(getIssueList(git, filter)).then((value) => {
36163
36906
  if (!active)
@@ -36165,13 +36908,21 @@ function LogInkApp(deps) {
36165
36908
  setContext((current) => ({
36166
36909
  ...current,
36167
36910
  issueList: value,
36168
- }));
36169
- setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
36911
+ }), issuedAtDepth);
36912
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'), issuedAtDepth);
36170
36913
  });
36171
36914
  return () => {
36172
36915
  active = false;
36173
36916
  };
36174
- }, [git, state.activeView, context.issueList, state.selectedIssueFilter]);
36917
+ }, [
36918
+ git,
36919
+ runtimes.length,
36920
+ state.activeView,
36921
+ context.issueList,
36922
+ state.selectedIssueFilter,
36923
+ setContext,
36924
+ setContextStatus,
36925
+ ]);
36175
36926
  // Filter cycling: when the preset changes, drop the cached list
36176
36927
  // so the effect above re-fires with the new filter. Done as a
36177
36928
  // separate effect (rather than folded into the cycle reducer)
@@ -36195,8 +36946,9 @@ function LogInkApp(deps) {
36195
36946
  return;
36196
36947
  if (context.pullRequestList)
36197
36948
  return;
36949
+ const issuedAtDepth = runtimes.length - 1;
36198
36950
  let active = true;
36199
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
36951
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'), issuedAtDepth);
36200
36952
  const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
36201
36953
  void safe(getPullRequestList(git, filter)).then((value) => {
36202
36954
  if (!active)
@@ -36204,13 +36956,21 @@ function LogInkApp(deps) {
36204
36956
  setContext((current) => ({
36205
36957
  ...current,
36206
36958
  pullRequestList: value,
36207
- }));
36208
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
36959
+ }), issuedAtDepth);
36960
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'), issuedAtDepth);
36209
36961
  });
36210
36962
  return () => {
36211
36963
  active = false;
36212
36964
  };
36213
- }, [git, state.activeView, context.pullRequestList, state.selectedPullRequestFilter]);
36965
+ }, [
36966
+ git,
36967
+ runtimes.length,
36968
+ state.activeView,
36969
+ context.pullRequestList,
36970
+ state.selectedPullRequestFilter,
36971
+ setContext,
36972
+ setContextStatus,
36973
+ ]);
36214
36974
  React.useEffect(() => {
36215
36975
  if (state.activeView !== 'pull-request-triage')
36216
36976
  return;
@@ -36240,6 +37000,7 @@ function LogInkApp(deps) {
36240
37000
  return;
36241
37001
  if (context.issueDetailByNumber?.has(cursored.number))
36242
37002
  return;
37003
+ const issuedAtDepth = runtimes.length - 1;
36243
37004
  let active = true;
36244
37005
  const timer = setTimeout(async () => {
36245
37006
  const result = await getIssueDetail(cursored.number);
@@ -36248,17 +37009,19 @@ function LogInkApp(deps) {
36248
37009
  setContext((current) => ({
36249
37010
  ...current,
36250
37011
  issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
36251
- }));
37012
+ }), issuedAtDepth);
36252
37013
  }, DETAIL_HYDRATION_DELAY_MS);
36253
37014
  return () => {
36254
37015
  active = false;
36255
37016
  clearTimeout(timer);
36256
37017
  };
36257
37018
  }, [
37019
+ runtimes.length,
36258
37020
  state.activeView,
36259
37021
  state.selectedIssueIndex,
36260
37022
  filteredIssueList,
36261
37023
  context.issueDetailByNumber,
37024
+ setContext,
36262
37025
  ]);
36263
37026
  React.useEffect(() => {
36264
37027
  if (state.activeView !== 'pull-request-triage')
@@ -36268,6 +37031,7 @@ function LogInkApp(deps) {
36268
37031
  return;
36269
37032
  if (context.pullRequestDetailByNumber?.has(cursored.number))
36270
37033
  return;
37034
+ const issuedAtDepth = runtimes.length - 1;
36271
37035
  let active = true;
36272
37036
  const timer = setTimeout(async () => {
36273
37037
  const result = await getPullRequestDetail(cursored.number);
@@ -36276,17 +37040,19 @@ function LogInkApp(deps) {
36276
37040
  setContext((current) => ({
36277
37041
  ...current,
36278
37042
  pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
36279
- }));
37043
+ }), issuedAtDepth);
36280
37044
  }, DETAIL_HYDRATION_DELAY_MS);
36281
37045
  return () => {
36282
37046
  active = false;
36283
37047
  clearTimeout(timer);
36284
37048
  };
36285
37049
  }, [
37050
+ runtimes.length,
36286
37051
  state.activeView,
36287
37052
  state.selectedPullRequestTriageIndex,
36288
37053
  filteredPullRequestTriageList,
36289
37054
  context.pullRequestDetailByNumber,
37055
+ setContext,
36290
37056
  ]);
36291
37057
  React.useEffect(() => {
36292
37058
  let active = true;
@@ -36547,21 +37313,96 @@ function LogInkApp(deps) {
36547
37313
  state.commitCompose.body,
36548
37314
  state.commitCompose.summary,
36549
37315
  ]);
37316
+ // AbortController for the in-flight AI draft (#881 phase 3). Kept in
37317
+ // a ref rather than state because cancel is a side-effect: the input
37318
+ // handler reads `controllerRef.current?.abort()` synchronously when
37319
+ // Esc fires during a loading draft. Storing it in state would force
37320
+ // a re-render on every set, and React doesn't need to know — only
37321
+ // the imperative cancel path does. Cleared after each call settles
37322
+ // so a stale controller can't cancel a future draft.
37323
+ const aiDraftAbortRef = React.useRef(null);
36550
37324
  const runAiCommitDraft = React.useCallback(async () => {
37325
+ // Tear down any controller from a previous draft (defensive — a
37326
+ // settled call should have cleared it in the finally block, but
37327
+ // double-running would otherwise leave the first orphaned).
37328
+ aiDraftAbortRef.current?.abort();
37329
+ const controller = new AbortController();
37330
+ aiDraftAbortRef.current = controller;
36551
37331
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
36552
37332
  dispatch({ type: 'setStatus', value: 'generating AI commit draft', loading: true });
36553
- const result = await runCommitDraftWorkflow();
36554
- if (result.ok && result.draft) {
36555
- dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
36556
- dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
36557
- return;
37333
+ // Streaming preview (#881 phase 2). The workflow forwards this to
37334
+ // `generateCommitDraft`, which only actually streams when the
37335
+ // user opted in via `service.streaming.enabled`. The callback
37336
+ // updates `commitCompose.streamingPreview` so the compose surface
37337
+ // renders a live last-N-lines preview below the loader. The
37338
+ // reducer clears `streamingPreview` whenever loading flips off
37339
+ // (success or failure), so we don't need an explicit teardown
37340
+ // dispatch here.
37341
+ try {
37342
+ const result = await runCommitDraftWorkflow({
37343
+ git,
37344
+ signal: controller.signal,
37345
+ onStreamChunk: (_text, accumulated) => {
37346
+ // Dispatch the full accumulated text — the preview chrome
37347
+ // helper does the last-N-lines slicing at render time, so
37348
+ // re-doing the slice here would be wasted work. Per-chunk
37349
+ // dispatches are cheap; React batches them and Ink redraws
37350
+ // at its own frame cadence.
37351
+ dispatch({
37352
+ type: 'commitCompose',
37353
+ action: { type: 'setStreamingPreview', value: accumulated },
37354
+ });
37355
+ },
37356
+ });
37357
+ // Cancel path (#881 phase 3). User pressed Esc during the
37358
+ // stream; reducer drops loading + preview, status line shows
37359
+ // a neutral "cancelled" message. Skip the result / failure
37360
+ // dispatches because the user already knows what happened.
37361
+ if (result.cancelled) {
37362
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37363
+ dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
37364
+ return;
37365
+ }
37366
+ if (result.ok && result.draft) {
37367
+ dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
37368
+ dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
37369
+ return;
37370
+ }
37371
+ dispatch({
37372
+ type: 'commitCompose',
37373
+ action: { type: 'setResult', message: result.message, details: result.details },
37374
+ });
37375
+ dispatch({ type: 'setStatus', value: result.message });
36558
37376
  }
36559
- dispatch({
36560
- type: 'commitCompose',
36561
- action: { type: 'setResult', message: result.message, details: result.details },
36562
- });
36563
- dispatch({ type: 'setStatus', value: result.message });
36564
- }, [dispatch]);
37377
+ finally {
37378
+ // Clear the ref only if it still points at OUR controller — a
37379
+ // rapid second invocation could have already replaced it, in
37380
+ // which case the new controller is the one that owns cancel
37381
+ // duty now.
37382
+ if (aiDraftAbortRef.current === controller) {
37383
+ aiDraftAbortRef.current = null;
37384
+ }
37385
+ }
37386
+ }, [dispatch, git]);
37387
+ /**
37388
+ * Cancel an in-flight AI draft (#881 phase 3). Called by the input
37389
+ * handler when the user presses Esc while `commitCompose.loading`
37390
+ * is true. Idempotent — calling without an active controller is a
37391
+ * no-op rather than an error so the keystroke handler can fire
37392
+ * unconditionally during the loading window.
37393
+ *
37394
+ * `controller.abort()` propagates through
37395
+ * `executeChainStreaming`, which throws `LangChainCancelledError`,
37396
+ * which becomes `cancelled: true` on the workflow result. The
37397
+ * runAiCommitDraft promise's finally block clears the ref. The
37398
+ * resulting cleanup dispatches (clearing loading + status) happen
37399
+ * back in `runAiCommitDraft`, not here, so this function stays
37400
+ * pure-imperative and the React state updates flow through a
37401
+ * single code path.
37402
+ */
37403
+ const cancelAiCommitDraft = React.useCallback(() => {
37404
+ aiDraftAbortRef.current?.abort();
37405
+ }, []);
36565
37406
  // `C` keystroke handler — start the create-pull-request flow. Resolves
36566
37407
  // the head + base branches from the live context, runs
36567
37408
  // `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
@@ -36575,6 +37416,19 @@ function LogInkApp(deps) {
36575
37416
  // missing) we surface the failure on the status line and skip the
36576
37417
  // prompt entirely — better than opening a prompt the user can't
36577
37418
  // actually submit successfully.
37419
+ // Soft-cancel handle for the PR body draft (#881 phase 4). A mutable
37420
+ // ref rather than state because the cancel decision needs to be
37421
+ // visible synchronously inside the async workflow without forcing
37422
+ // re-renders. Owned by the in-flight invocation: the cancel callback
37423
+ // mutates `.cancelled` on the live ref; the workflow checks it after
37424
+ // `await` resolves and decides whether to open the follow-up prompt.
37425
+ //
37426
+ // The LLM call itself keeps running (no AbortSignal threaded through
37427
+ // `changelogHandler` today). The user-visible outcome — "PR draft
37428
+ // cancelled, no prompt opens" — is identical to a hard cancel, at
37429
+ // the cost of paying for the in-flight tokens. Deeper threading
37430
+ // lands in a follow-up if hard cancel becomes a request.
37431
+ const pullRequestBodyCancelRef = React.useRef(null);
36578
37432
  const startCreatePullRequest = React.useCallback(async () => {
36579
37433
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
36580
37434
  if (!head) {
@@ -36603,32 +37457,61 @@ function LogInkApp(deps) {
36603
37457
  });
36604
37458
  return;
36605
37459
  }
37460
+ // Set up the cancel handle BEFORE flipping the pending flag so a
37461
+ // race between the flag-set and a synchronous Esc keystroke can't
37462
+ // leave the input handler dispatching cancel without a ref to
37463
+ // mutate. The cancel callback no-ops cleanly when the ref is null
37464
+ // (call already settled).
37465
+ const cancelHandle = { cancelled: false };
37466
+ pullRequestBodyCancelRef.current = cancelHandle;
37467
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
36606
37468
  dispatch({
36607
37469
  type: 'setStatus',
36608
- value: `generating PR body from changelog (vs ${defaultBranch})…`,
37470
+ value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
36609
37471
  loading: true,
36610
37472
  });
36611
- const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
36612
- // Fallback shape when the changelog generation fails — open the
36613
- // prompt with empty title + body rather than aborting, so the user
36614
- // can still author the PR manually. The status line surfaces why
36615
- // we couldn't pre-fill.
36616
- const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
36617
- const initialBody = body.body || '';
36618
- const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
36619
- if (!body.ok) {
36620
- dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
37473
+ try {
37474
+ const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
37475
+ // Soft-cancel check (#881 phase 4). If the user pressed Esc
37476
+ // while the workflow was awaiting, skip opening the prompt and
37477
+ // surface a neutral status. The underlying LLM call has
37478
+ // already settled its result is discarded. Hard cancel
37479
+ // (aborting the HTTP request mid-flight) is a follow-up.
37480
+ if (cancelHandle.cancelled) {
37481
+ dispatch({ type: 'setStatus', value: 'PR draft cancelled.' });
37482
+ return;
37483
+ }
37484
+ // Fallback shape when the changelog generation fails — open the
37485
+ // prompt with empty title + body rather than aborting, so the user
37486
+ // can still author the PR manually. The status line surfaces why
37487
+ // we couldn't pre-fill.
37488
+ const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
37489
+ const initialBody = body.body || '';
37490
+ const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
37491
+ if (!body.ok) {
37492
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
37493
+ }
37494
+ else {
37495
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
37496
+ }
37497
+ dispatch({
37498
+ type: 'openInputPrompt',
37499
+ kind: 'create-pr',
37500
+ label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
37501
+ initial,
37502
+ multiline: true,
37503
+ });
36621
37504
  }
36622
- else {
36623
- dispatch({ type: 'setStatus', value: 'PR body drafted review and Ctrl+D to submit.' });
37505
+ finally {
37506
+ // Clear the flag + the ref so a subsequent draft starts clean.
37507
+ // Only clear the ref if we still own it — a second invocation
37508
+ // would have already taken ownership in which case the cancel
37509
+ // duty has rolled over.
37510
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37511
+ if (pullRequestBodyCancelRef.current === cancelHandle) {
37512
+ pullRequestBodyCancelRef.current = null;
37513
+ }
36624
37514
  }
36625
- dispatch({
36626
- type: 'openInputPrompt',
36627
- kind: 'create-pr',
36628
- label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
36629
- initial,
36630
- multiline: true,
36631
- });
36632
37515
  }, [
36633
37516
  context.branches?.currentBranch,
36634
37517
  context.provider?.currentBranch,
@@ -36637,6 +37520,24 @@ function LogInkApp(deps) {
36637
37520
  context.pullRequest?.currentPullRequest,
36638
37521
  dispatch,
36639
37522
  ]);
37523
+ /**
37524
+ * Soft-cancel the in-flight PR body draft (#881 phase 4). The
37525
+ * cancel ref's `.cancelled` flag is checked after the workflow's
37526
+ * await resolves; setting it true causes the workflow to skip the
37527
+ * prompt-open and surface a neutral "cancelled" status. The LLM
37528
+ * call itself isn't aborted (no signal threaded through the
37529
+ * `changelogHandler` chain) so the user still pays for the in-flight
37530
+ * tokens. Acceptable for a 5-15s draft; hard cancel lands in a
37531
+ * follow-up if it becomes a real ask.
37532
+ *
37533
+ * Idempotent — calling without an active draft is a no-op.
37534
+ */
37535
+ const cancelPullRequestBodyDraft = React.useCallback(() => {
37536
+ const handle = pullRequestBodyCancelRef.current;
37537
+ if (!handle)
37538
+ return;
37539
+ handle.cancelled = true;
37540
+ }, []);
36640
37541
  // Copy an arbitrary string to the system clipboard. Distinct from
36641
37542
  // `yankFromActiveView` which derives the value from the current view
36642
37543
  // — this one takes the value as an explicit event payload, used by
@@ -37062,11 +37963,18 @@ function LogInkApp(deps) {
37062
37963
  type: 'setSplitPlanReady',
37063
37964
  plan: result.plan,
37064
37965
  planContext: result.planContext,
37966
+ fallback: result.fallback,
37065
37967
  });
37968
+ const readyMessage = result.fallback
37969
+ ? `Split planner exhausted retries — showing single-commit fallback. y/Enter to apply as one commit, r to re-roll, Esc to cancel.`
37970
+ : `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`;
37971
+ // Use 'info' kind for the fallback path (still actionable, just
37972
+ // not a clean win). The reducer's "warning" is the absence of
37973
+ // `success` framing — the message text itself carries the cue.
37066
37974
  dispatch({
37067
37975
  type: 'setStatus',
37068
- value: `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`,
37069
- kind: 'success',
37976
+ value: readyMessage,
37977
+ kind: result.fallback ? 'info' : 'success',
37070
37978
  });
37071
37979
  }, [context.operation, context.worktree?.stagedCount, dispatch, git]);
37072
37980
  // `y`/Enter inside the overlay — apply the previewed plan. Uses the
@@ -37108,6 +38016,7 @@ function LogInkApp(deps) {
37108
38016
  plan: splitPlan.plan,
37109
38017
  planContext: splitPlan.planContext,
37110
38018
  git,
38019
+ fallback: splitPlan.fallback,
37111
38020
  });
37112
38021
  dump.push(`workflow returned: ok=${result.ok} message="${result.message}" commitHashes=[${(result.commitHashes || []).join(', ')}]`);
37113
38022
  try {
@@ -37202,8 +38111,15 @@ function LogInkApp(deps) {
37202
38111
  });
37203
38112
  return;
37204
38113
  }
37205
- const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
37206
- dispatch({ type: 'setStatus', value: successMessage, kind: 'success' });
38114
+ const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked, result.fallback ? { reason: result.fallback.reason } : undefined);
38115
+ // Fallback path uses 'info' kind apply technically succeeded
38116
+ // but the user should know it landed as a single combined commit
38117
+ // rather than a real LLM-driven multi-group split.
38118
+ dispatch({
38119
+ type: 'setStatus',
38120
+ value: successMessage,
38121
+ kind: result.fallback ? 'info' : 'success',
38122
+ });
37207
38123
  }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
37208
38124
  // Esc inside the overlay — close without applying. Status line gets
37209
38125
  // a confirmation so the user knows the operation was abandoned.
@@ -38663,9 +39579,15 @@ function LogInkApp(deps) {
38663
39579
  else if (event.type === 'runAiCommitDraft') {
38664
39580
  void runAiCommitDraft();
38665
39581
  }
39582
+ else if (event.type === 'cancelAiCommitDraft') {
39583
+ cancelAiCommitDraft();
39584
+ }
38666
39585
  else if (event.type === 'startCreatePullRequest') {
38667
39586
  void startCreatePullRequest();
38668
39587
  }
39588
+ else if (event.type === 'cancelPullRequestBodyDraft') {
39589
+ cancelPullRequestBodyDraft();
39590
+ }
38669
39591
  else if (event.type === 'startChangelogView') {
38670
39592
  void startChangelogView();
38671
39593
  }