git-coco 0.52.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.
@@ -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.52.0";
64
+ const BUILD_VERSION = "0.54.0";
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
  /**
@@ -15612,6 +15752,7 @@ Structural rules:
15612
15752
  - Only use hunk IDs LITERALLY copied from the "Staged hunk inventory" section below. Do not invent or guess hunk IDs.
15613
15753
  - If the hunk inventory says "No hunk-level inventory available" then EVERY group's "hunks" array MUST be empty (use only "files"). Do not write hunk IDs like "path::hunk-1" when no hunk inventory exists — those are not valid.
15614
15754
  - Prefer 2-5 commits unless the changes are truly all one topic.
15755
+ - Order the groups in the sequence they would logically be built — foundational changes first, consumers after. If group B uses a symbol, function, type, or file introduced in group A, A MUST appear before B in the array. The applier commits in array order, so this order becomes the git history. Example: a "feat: add helpers" group that introduces \`formatX()\` must come before a "feat: wire helpers into renderer" group that calls \`formatX()\`, even if the staged diff is presented in the opposite order. When two groups have no dependency relationship, prefer the one closer to a "scaffold" (types, config, new files) before the one closer to a "use site" (existing files modified to consume the new code).
15615
15756
 
15616
15757
  Commit message style:
15617
15758
  - Write each "title" in the imperative mood ("add", not "added"), under 72 chars.
@@ -15768,7 +15909,7 @@ async function applyPatchToIndex$1(patch, git) {
15768
15909
  child.stdin.end();
15769
15910
  });
15770
15911
  }
15771
- async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, }) {
15912
+ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, fallback, }) {
15772
15913
  validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
15773
15914
  assertNoUnstagedOverlap(plan, changes, hunkInventory);
15774
15915
  // Defensive: drop any group with empty files[] AND empty hunks[].
@@ -15871,11 +16012,13 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
15871
16012
  return {
15872
16013
  commitHashes,
15873
16014
  message: `Created ${commitHashes.length} of ${applicableGroups.length} planned commit(s). Failed: ${partial}`,
16015
+ fallback,
15874
16016
  };
15875
16017
  }
15876
16018
  return {
15877
16019
  commitHashes,
15878
16020
  message: `Created ${commitHashes.length} split commit(s).`,
16021
+ fallback,
15879
16022
  };
15880
16023
  }
15881
16024
  /**
@@ -15973,7 +16116,7 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15973
16116
  }
15974
16117
  const resolvedPlanLlm = planLlm ?? llm;
15975
16118
  const resolvedPlanModel = planService?.model ?? config.service.model;
15976
- const { plan } = await generateValidatedCommitSplitPlan({
16119
+ const { plan, fallback } = await generateValidatedCommitSplitPlan({
15977
16120
  llm: resolvedPlanLlm,
15978
16121
  prompt: COMMIT_SPLIT_PROMPT,
15979
16122
  variables: {
@@ -15996,8 +16139,12 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15996
16139
  conventional: useConventional,
15997
16140
  },
15998
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),
15999
16146
  });
16000
- return { plan, context: { changes, hunkInventory } };
16147
+ return { plan, context: { changes, hunkInventory }, fallback };
16001
16148
  }
16002
16149
  async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
16003
16150
  const result = await prepareCommitSplitPlan({
@@ -16013,7 +16160,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
16013
16160
  if ('empty' in result) {
16014
16161
  return 'No staged changes found.';
16015
16162
  }
16016
- const { plan, context } = result;
16163
+ const { plan, context, fallback } = result;
16017
16164
  if (argv.apply) {
16018
16165
  const applied = await applyCommitSplitPlan({
16019
16166
  plan,
@@ -16022,9 +16169,24 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
16022
16169
  git,
16023
16170
  logger,
16024
16171
  noVerify: argv.noVerify || config.noVerify || false,
16172
+ fallback,
16025
16173
  });
16174
+ if (applied.fallback) {
16175
+ return [
16176
+ `Note: applied the single-commit fallback (${applied.fallback.reason}).`,
16177
+ applied.message,
16178
+ ].join('\n');
16179
+ }
16026
16180
  return applied.message;
16027
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
+ }
16028
16190
  return formatCommitSplitPlan(plan);
16029
16191
  }
16030
16192
 
@@ -20063,9 +20225,13 @@ function applyCommitComposeAction(state, action) {
20063
20225
  editing: action.value,
20064
20226
  };
20065
20227
  case 'setLoading':
20228
+ // Clearing loading also clears any in-flight streaming preview;
20229
+ // the preview's whole purpose is to fill the wait window. Once
20230
+ // the wait ends (success OR failure), the preview is stale.
20066
20231
  return {
20067
20232
  ...state,
20068
20233
  loading: action.value,
20234
+ streamingPreview: action.value ? state.streamingPreview : undefined,
20069
20235
  };
20070
20236
  case 'setDraft':
20071
20237
  // No `message` here — the loader → filled fields are the confirmation
@@ -20081,6 +20247,7 @@ function applyCommitComposeAction(state, action) {
20081
20247
  loading: false,
20082
20248
  message: undefined,
20083
20249
  details: undefined,
20250
+ streamingPreview: undefined,
20084
20251
  };
20085
20252
  case 'setResult':
20086
20253
  return {
@@ -20088,6 +20255,17 @@ function applyCommitComposeAction(state, action) {
20088
20255
  loading: false,
20089
20256
  message: action.message,
20090
20257
  details: action.details,
20258
+ streamingPreview: undefined,
20259
+ };
20260
+ case 'setStreamingPreview':
20261
+ // Per-chunk live-preview update. Fires from the streaming
20262
+ // workflow's onChunk callback; the renderer turns it into a
20263
+ // last-N-lines panel below the loading line. Pass `undefined`
20264
+ // to explicitly clear (the workflow does this on completion
20265
+ // alongside the `setDraft` / `setResult` dispatch).
20266
+ return {
20267
+ ...state,
20268
+ streamingPreview: action.value,
20091
20269
  };
20092
20270
  case 'reset':
20093
20271
  // Drop message/details too — the post-commit "Created commit ..."
@@ -20161,6 +20339,210 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
20161
20339
  }
20162
20340
  }
20163
20341
 
20342
+ /**
20343
+ * Same provider / endpoint best-effort extraction `executeChain` uses,
20344
+ * duplicated here rather than imported so the streaming module doesn't
20345
+ * pull on `executeChain`'s implementation. If both helpers ever need to
20346
+ * share more, factor this out to a shared `llmInfo.ts`.
20347
+ */
20348
+ function extractLlmInfo(llm) {
20349
+ const info = {};
20350
+ const className = llm?.constructor?.name || '';
20351
+ if (className.includes('Ollama')) {
20352
+ info.provider = 'ollama';
20353
+ if ('lc_kwargs' in llm && typeof llm.lc_kwargs === 'object' && llm.lc_kwargs !== null) {
20354
+ const kwargs = llm.lc_kwargs;
20355
+ if (typeof kwargs.baseUrl === 'string') {
20356
+ info.endpoint = kwargs.baseUrl;
20357
+ }
20358
+ }
20359
+ }
20360
+ else if (className.includes('OpenAI')) {
20361
+ info.provider = 'openai';
20362
+ }
20363
+ else if (className.includes('Anthropic')) {
20364
+ info.provider = 'anthropic';
20365
+ }
20366
+ return info;
20367
+ }
20368
+ /**
20369
+ * Coerce one streamed chunk into its text fragment. LangChain's
20370
+ * `prompt.pipe(llm).stream(...)` yields `BaseMessageChunk` instances
20371
+ * whose `.content` is sometimes a string and sometimes an array of
20372
+ * content parts (multi-modal models, tool calls). We only care about
20373
+ * the textual delta here; non-text parts are silently dropped because
20374
+ * phase 1's surfaces (stdout + status-line copy) can't render them
20375
+ * anyway.
20376
+ */
20377
+ function coerceChunkText(messageChunk) {
20378
+ if (typeof messageChunk === 'string')
20379
+ return messageChunk;
20380
+ if (messageChunk && typeof messageChunk === 'object' && 'content' in messageChunk) {
20381
+ const content = messageChunk.content;
20382
+ if (typeof content === 'string')
20383
+ return content;
20384
+ if (Array.isArray(content)) {
20385
+ // Multi-part content array — concatenate the text parts only.
20386
+ return content
20387
+ .map((part) => {
20388
+ if (typeof part === 'string')
20389
+ return part;
20390
+ if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
20391
+ return part.text;
20392
+ }
20393
+ return '';
20394
+ })
20395
+ .join('');
20396
+ }
20397
+ }
20398
+ return '';
20399
+ }
20400
+ /**
20401
+ * Streaming variant of `executeChain`. Pipes the prompt into the LLM,
20402
+ * consumes the resulting async iterable, fires `onChunk` with each text
20403
+ * fragment as it arrives, and runs the supplied parser against the
20404
+ * fully-accumulated text on completion. Returns the parsed result.
20405
+ *
20406
+ * Why a separate function instead of an `onChunk?` flag on
20407
+ * `executeChain`? Two reasons:
20408
+ *
20409
+ * 1. The two paths have meaningfully different failure modes — a
20410
+ * half-streamed result can be salvaged with a best-effort parse;
20411
+ * an `invoke()` failure can't. Separate functions let each handle
20412
+ * its own error shape cleanly.
20413
+ * 2. Callers should make an explicit choice about whether they want
20414
+ * streaming. Adding it as an opt-in flag on `executeChain` makes
20415
+ * it tempting to plumb `onChunk` from random surfaces; a separate
20416
+ * helper makes the call site say "yes, this needs streaming."
20417
+ *
20418
+ * No automatic fallback to non-streaming `executeChain` when streaming
20419
+ * fails — by design. Callers that want fallback should `catch` this
20420
+ * function and call `executeChain` themselves. Keeps the helper focused
20421
+ * on the streaming path and the fallback policy explicit at the call
20422
+ * site (different commands may want different fallback strategies).
20423
+ */
20424
+ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk, signal, provider, endpoint, logger, tokenizer, metadata, }) {
20425
+ validateRequired(llm, 'llm', 'executeChainStreaming');
20426
+ validateRequired(prompt, 'prompt', 'executeChainStreaming');
20427
+ validateRequired(variables, 'variables', 'executeChainStreaming');
20428
+ validateRequired(parser, 'parser', 'executeChainStreaming');
20429
+ validateRequired(onChunk, 'onChunk', 'executeChainStreaming');
20430
+ if (typeof variables !== 'object' || Array.isArray(variables)) {
20431
+ throw new LangChainExecutionError('executeChainStreaming: Variables must be a non-array object', { variables, type: typeof variables, isArray: Array.isArray(variables) });
20432
+ }
20433
+ // Pre-flight abort check (#881 phase 3). Callers that ran the cancel
20434
+ // path before reaching here shouldn't pay for prompt rendering or
20435
+ // request setup. Match the contract `chain.stream(..., { signal })`
20436
+ // would have honoured — throw `LangChainCancelledError` rather than
20437
+ // a bare `AbortError`.
20438
+ if (signal?.aborted) {
20439
+ throw new LangChainCancelledError('executeChainStreaming: Aborted before stream opened', '');
20440
+ }
20441
+ const llmInfo = extractLlmInfo(llm);
20442
+ const effectiveProvider = provider || llmInfo.provider;
20443
+ const effectiveEndpoint = endpoint || llmInfo.endpoint;
20444
+ let accumulated = '';
20445
+ try {
20446
+ const renderedPrompt = await prompt.format(variables);
20447
+ const promptTokens = estimatePromptTokens(tokenizer, renderedPrompt);
20448
+ const chain = prompt.pipe(llm);
20449
+ const startedAt = Date.now();
20450
+ // Forward the signal into LangChain's RunnableConfig. The HTTP
20451
+ // transport (openai / anthropic / ollama clients) honours it and
20452
+ // tears down the connection rather than waiting for the model to
20453
+ // finish. The async iterator throws an AbortError that we
20454
+ // classify below.
20455
+ const stream = await chain.stream(variables, signal ? { signal } : undefined);
20456
+ let chunkCount = 0;
20457
+ for await (const messageChunk of stream) {
20458
+ const text = coerceChunkText(messageChunk);
20459
+ if (!text)
20460
+ continue;
20461
+ accumulated += text;
20462
+ chunkCount += 1;
20463
+ try {
20464
+ onChunk({ text, accumulated });
20465
+ }
20466
+ catch (callbackError) {
20467
+ // Deliberately swallow callback errors so a bad render handler
20468
+ // can't tank the entire LLM call. Log at verbose so users with
20469
+ // verbose mode on can still see what happened.
20470
+ logger?.verbose(`executeChainStreaming: onChunk handler threw: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
20471
+ }
20472
+ }
20473
+ if (!accumulated) {
20474
+ throw new LangChainExecutionError('executeChainStreaming: Stream completed with no text chunks', { variables, promptInputVariables: prompt.inputVariables });
20475
+ }
20476
+ const result = (await parser.invoke(accumulated));
20477
+ const elapsedMs = Date.now() - startedAt;
20478
+ logLlmCall(logger, {
20479
+ task: metadata?.task || 'chain-streaming',
20480
+ provider: effectiveProvider,
20481
+ parserType: parser.constructor.name,
20482
+ variableKeys: Object.keys(variables),
20483
+ promptTokens,
20484
+ elapsedMs,
20485
+ // Surfaced in observability so consumers can spot the streaming
20486
+ // path in their logs without correlating across tools. `chunks`
20487
+ // doubles as a sanity check (a streaming call that delivered 1
20488
+ // chunk is functionally identical to a non-streaming one).
20489
+ streamed: true,
20490
+ streamChunks: chunkCount,
20491
+ ...metadata,
20492
+ });
20493
+ if (result === null || result === undefined) {
20494
+ throw new LangChainExecutionError('executeChainStreaming: Parser returned null or undefined from streamed text', {
20495
+ variables,
20496
+ promptInputVariables: prompt.inputVariables,
20497
+ accumulatedLength: accumulated.length,
20498
+ });
20499
+ }
20500
+ return result;
20501
+ }
20502
+ catch (error) {
20503
+ // Cancellation classifier (#881 phase 3). Three signals: an
20504
+ // explicitly aborted user signal (post-throw check), the
20505
+ // standard DOM `AbortError`, or a Node `AbortSignal` with
20506
+ // `signal.aborted === true` while a chain-internal error
20507
+ // propagates. Any of these means "user wanted out," not "the
20508
+ // call failed." Wrap the raw error so callers can pattern-match
20509
+ // on `LangChainCancelledError` and carry the partial accumulated
20510
+ // text in case the caller wants to salvage anything.
20511
+ const aborted = signal?.aborted ||
20512
+ (error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted')));
20513
+ if (aborted) {
20514
+ throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
20515
+ provider: effectiveProvider,
20516
+ endpoint: effectiveEndpoint,
20517
+ });
20518
+ }
20519
+ if (error instanceof LangChainExecutionError ||
20520
+ error instanceof LangChainNetworkError ||
20521
+ error instanceof LangChainCancelledError) {
20522
+ throw error;
20523
+ }
20524
+ if (error instanceof Error && isNetworkError(error)) {
20525
+ throw new LangChainNetworkError(error.message, effectiveEndpoint, effectiveProvider, {
20526
+ originalError: error.name,
20527
+ originalMessage: error.message,
20528
+ stack: error.stack,
20529
+ promptInputVariables: prompt.inputVariables,
20530
+ variableKeys: Object.keys(variables),
20531
+ parserType: parser.constructor.name,
20532
+ streamed: true,
20533
+ });
20534
+ }
20535
+ handleLangChainError(error, 'executeChainStreaming: Stream execution failed', {
20536
+ promptInputVariables: prompt.inputVariables,
20537
+ variableKeys: Object.keys(variables),
20538
+ parserType: parser.constructor.name,
20539
+ provider: effectiveProvider,
20540
+ endpoint: effectiveEndpoint,
20541
+ streamed: true,
20542
+ });
20543
+ }
20544
+ }
20545
+
20164
20546
  const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
20165
20547
 
20166
20548
  REQUIRED JSON FORMAT:
@@ -20185,7 +20567,45 @@ IMPORTANT RULES:
20185
20567
  * are surfaced as `validationErrors`/`warnings` rather than driving an
20186
20568
  * interactive retry flow — the TUI can re-invoke or let the user edit.
20187
20569
  */
20188
- async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), }) {
20570
+ /**
20571
+ * Fallback parser shared between the non-streaming
20572
+ * `executeChainWithSchema` call and the streaming path (#881 phase 2).
20573
+ *
20574
+ * Extracted from the inline `fallbackParser` option so the streaming
20575
+ * path can use the same lossy-but-permissive recovery for accumulated
20576
+ * text. Strips markdown code fences, attempts strict JSON parse, and
20577
+ * falls back to "first line is title, rest is body" when JSON parsing
20578
+ * fails entirely.
20579
+ *
20580
+ * Returned shape always satisfies the schema's structural requirements
20581
+ * (`title` + `body` strings) but the *content* may be the last-ditch
20582
+ * "Auto-generated commit" placeholder. Callers should treat this as a
20583
+ * best-effort salvage, not a parse confirmation.
20584
+ */
20585
+ function salvageCommitMessageFromText(text) {
20586
+ try {
20587
+ let cleanText = text.trim();
20588
+ const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
20589
+ if (codeBlockMatch && codeBlockMatch[1]) {
20590
+ cleanText = codeBlockMatch[1].trim();
20591
+ }
20592
+ const parsed = JSON.parse(cleanText);
20593
+ if (parsed && typeof parsed === 'object' &&
20594
+ typeof parsed.title === 'string' &&
20595
+ typeof parsed.body === 'string' &&
20596
+ parsed.title.length > 0) {
20597
+ return parsed;
20598
+ }
20599
+ }
20600
+ catch {
20601
+ // fall through to line-split salvage
20602
+ }
20603
+ return {
20604
+ title: text.split('\n')[0] || 'Auto-generated commit',
20605
+ body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
20606
+ };
20607
+ }
20608
+ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), onStreamChunk, signal, }) {
20189
20609
  const config = loadConfig(argv);
20190
20610
  const key = getApiKeyForModel(config);
20191
20611
  const { provider } = getModelAndProviderFromConfig(config);
@@ -20328,42 +20748,117 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
20328
20748
  tokenizer,
20329
20749
  maxTokens: config.service.tokenLimit || 2048,
20330
20750
  });
20331
- const commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20332
- logger,
20333
- tokenizer,
20334
- metadata: {
20335
- task: useConventional ? 'commit-message-conventional' : 'commit-message',
20336
- command: 'commit-draft',
20337
- provider,
20338
- model: String(model),
20339
- },
20340
- retryOptions: {
20341
- maxAttempts: maxParsingAttempts,
20342
- },
20343
- fallbackParser: (text) => {
20344
- try {
20345
- let cleanText = text.trim();
20346
- const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
20347
- if (codeBlockMatch && codeBlockMatch[1]) {
20348
- cleanText = codeBlockMatch[1].trim();
20349
- }
20350
- const parsed = JSON.parse(cleanText);
20351
- if (parsed && typeof parsed === 'object' &&
20352
- typeof parsed.title === 'string' &&
20353
- typeof parsed.body === 'string' &&
20354
- parsed.title.length > 0) {
20355
- return parsed;
20356
- }
20357
- }
20358
- catch {
20359
- // fall through
20751
+ // Streaming path (#881 phase 2). Active when the caller supplied
20752
+ // an `onStreamChunk` AND the config opted in. Only the FIRST
20753
+ // attempt streams; the commitlint-retry attempt (attempt === 2)
20754
+ // and the existing executeChainWithSchema retry loop run
20755
+ // non-streaming so we keep the schema-validated retry as the
20756
+ // backstop when the streamed text can't be salvaged.
20757
+ const streamingEnabled = Boolean(onStreamChunk && config.service.streaming?.enabled);
20758
+ const shouldStreamThisAttempt = streamingEnabled && attempt === 1;
20759
+ let commitMsg;
20760
+ if (shouldStreamThisAttempt && onStreamChunk) {
20761
+ // The streaming chain bypasses the schema parser during the
20762
+ // stream itself (no streaming-aware JSON parser today) and
20763
+ // delivers the raw accumulated text to a no-op `parser.invoke`.
20764
+ // We then salvage the structured result via the same lossy
20765
+ // recovery the non-streaming fallbackParser uses. If the
20766
+ // salvager produces a plausible draft, we use it. Otherwise we
20767
+ // fall through to executeChainWithSchema below for a real
20768
+ // schema-validated retry — paying for a second LLM call only
20769
+ // on the edge case where the streamed output is unsalvageable.
20770
+ const streamingParser = createSchemaParser(schema, llm);
20771
+ let salvaged;
20772
+ try {
20773
+ // `executeChainStreaming` runs the parser on the accumulated
20774
+ // text at completion. StructuredOutputParser will throw when
20775
+ // the model produced unparseable JSON — we catch that below
20776
+ // and salvage manually. The happy-path zod-validated object
20777
+ // becomes our commitMsg.
20778
+ commitMsg = await executeChainStreaming({
20779
+ llm,
20780
+ prompt,
20781
+ variables: budgetedPrompt.variables,
20782
+ parser: streamingParser,
20783
+ onChunk: ({ text, accumulated }) => {
20784
+ onStreamChunk(text, accumulated);
20785
+ },
20786
+ signal,
20787
+ logger,
20788
+ tokenizer,
20789
+ metadata: {
20790
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20791
+ command: 'commit-draft',
20792
+ provider,
20793
+ model: String(model),
20794
+ },
20795
+ });
20796
+ }
20797
+ catch (streamErr) {
20798
+ // User-initiated cancel (#881 phase 3). Bail out of the
20799
+ // entire attempt loop and let the caller distinguish
20800
+ // "cancelled" from "failed" in the status line. We do NOT
20801
+ // fall through to the non-streaming retry on cancel — the
20802
+ // user explicitly asked to stop, kicking off a fresh
20803
+ // unstreamable LLM call would defy that intent.
20804
+ if (streamErr instanceof LangChainCancelledError) {
20805
+ return {
20806
+ ok: false,
20807
+ draft: streamErr.accumulated || '',
20808
+ warnings,
20809
+ validationErrors: [],
20810
+ cancelled: true,
20811
+ };
20360
20812
  }
20361
- return {
20362
- title: text.split('\n')[0] || 'Auto-generated commit',
20363
- body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
20364
- };
20365
- },
20366
- });
20813
+ // Streamed accumulated text didn't parse cleanly. Try the
20814
+ // lossy salvager on whatever we have; if that produces a
20815
+ // non-placeholder title, accept it. Otherwise fall through
20816
+ // to the non-streaming path which can retry with a fresh
20817
+ // LLM call.
20818
+ logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
20819
+ salvaged = undefined;
20820
+ }
20821
+ // Type-narrow: commitMsg is set inside try{}, but TS doesn't
20822
+ // see that across the catch. Re-init through the salvage path
20823
+ // if streaming threw.
20824
+ if (salvaged) {
20825
+ commitMsg = salvaged;
20826
+ }
20827
+ else if (!(commitMsg)) {
20828
+ // Streaming threw; do the standard non-streaming flow to
20829
+ // recover. This is the trade-off documented in the issue —
20830
+ // streaming gives us a preview but the validated result still
20831
+ // comes from the schema-aware retry path when streaming fails.
20832
+ commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20833
+ logger,
20834
+ tokenizer,
20835
+ metadata: {
20836
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20837
+ command: 'commit-draft',
20838
+ provider,
20839
+ model: String(model),
20840
+ },
20841
+ retryOptions: { maxAttempts: maxParsingAttempts },
20842
+ fallbackParser: salvageCommitMessageFromText,
20843
+ });
20844
+ }
20845
+ }
20846
+ else {
20847
+ commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
20848
+ logger,
20849
+ tokenizer,
20850
+ metadata: {
20851
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
20852
+ command: 'commit-draft',
20853
+ provider,
20854
+ model: String(model),
20855
+ },
20856
+ retryOptions: {
20857
+ maxAttempts: maxParsingAttempts,
20858
+ },
20859
+ fallbackParser: salvageCommitMessageFromText,
20860
+ });
20861
+ }
20367
20862
  const ticketId = extractTicketIdFromBranchName(branchName);
20368
20863
  const fullMessage = formatCommitMessage(commitMsg, {
20369
20864
  append: argv.append,
@@ -20461,8 +20956,26 @@ async function runCommitDraftWorkflow(input = {}) {
20461
20956
  const argv = createCommitWorkflowArgv('commit');
20462
20957
  const logger = new Logger({ silent: true });
20463
20958
  try {
20464
- const result = await generateCommitDraft({ git, argv, logger });
20959
+ const result = await generateCommitDraft({
20960
+ git,
20961
+ argv,
20962
+ logger,
20963
+ onStreamChunk: input.onStreamChunk,
20964
+ signal: input.signal,
20965
+ });
20465
20966
  const draft = result.draft.trim();
20967
+ // Cancel path (#881 phase 3). Reported separately from success
20968
+ // / failure so the runtime can render a neutral "cancelled"
20969
+ // status line instead of an error.
20970
+ if (result.cancelled) {
20971
+ return {
20972
+ ok: false,
20973
+ message: 'AI draft cancelled.',
20974
+ details: [],
20975
+ draft: '',
20976
+ cancelled: true,
20977
+ };
20978
+ }
20466
20979
  if (result.ok && draft) {
20467
20980
  return {
20468
20981
  ok: true,
@@ -20551,6 +21064,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
20551
21064
  ok: true,
20552
21065
  plan: result.plan,
20553
21066
  planContext: result.context,
21067
+ fallback: result.fallback,
20554
21068
  };
20555
21069
  }
20556
21070
  catch (error) {
@@ -20595,6 +21109,7 @@ async function runCommitSplitApplyWorkflow(input) {
20595
21109
  git,
20596
21110
  logger,
20597
21111
  noVerify: input.noVerify || false,
21112
+ fallback: input.fallback,
20598
21113
  });
20599
21114
  return {
20600
21115
  ok: true,
@@ -20605,6 +21120,7 @@ async function runCommitSplitApplyWorkflow(input) {
20605
21120
  // I/O AND inaccurate when partial-apply landed fewer commits
20606
21121
  // than the plan had groups.
20607
21122
  commitHashes: applied.commitHashes,
21123
+ fallback: applied.fallback,
20608
21124
  };
20609
21125
  }
20610
21126
  catch (error) {
@@ -21068,6 +21584,37 @@ function getLogInkWorkflowActions() {
21068
21584
  kind: 'normal',
21069
21585
  requiresConfirmation: false,
21070
21586
  },
21587
+ // Per-view variants of fetch / pull / push that act on the
21588
+ // cursored branch instead of the current one. Empty `key` keeps
21589
+ // them palette-discoverable without registering a global hotkey —
21590
+ // inkInput.ts dispatches them contextually when the user presses
21591
+ // F / U / P while the branches sidebar is focused. Outside that
21592
+ // context, the F / U / P keys still fire the global *-current-*
21593
+ // / fetch-remotes variants above.
21594
+ {
21595
+ id: 'fetch-selected-branch',
21596
+ key: '',
21597
+ label: 'Fetch selected branch',
21598
+ description: 'Run `git fetch <remote> <branch>` for the cursored branch in the branches view / sidebar.',
21599
+ kind: 'normal',
21600
+ requiresConfirmation: false,
21601
+ },
21602
+ {
21603
+ id: 'pull-selected-branch',
21604
+ key: '',
21605
+ label: 'Pull selected branch',
21606
+ description: 'Pull the cursored branch in the branches view / sidebar. Falls back to a fast-forward-only refspec fetch when the branch is not currently checked out; refuses non-FF.',
21607
+ kind: 'normal',
21608
+ requiresConfirmation: false,
21609
+ },
21610
+ {
21611
+ id: 'push-selected-branch',
21612
+ key: '',
21613
+ label: 'Push selected branch',
21614
+ description: 'Run `git push <remote> <branch>` for the cursored branch in the branches view / sidebar.',
21615
+ kind: 'normal',
21616
+ requiresConfirmation: false,
21617
+ },
21071
21618
  {
21072
21619
  // Per-view-only — the inkInput handler scopes this to the tags
21073
21620
  // surface so we don't expose `R` as a remote-delete from elsewhere.
@@ -22008,8 +22555,22 @@ function getLogInkFooterHints(options) {
22008
22555
  // "enter open" hint that drills into the dedicated view.
22009
22556
  const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
22010
22557
  if (itemsPresent && options.sidebarTab === 'branches') {
22558
+ // P / U / F fire the global pull-current-branch, push-current-branch,
22559
+ // fetch-remotes workflows — already implemented, just not visible in
22560
+ // the footer before. Surfacing them here matters because the user's
22561
+ // attention is on a branch when the branches sidebar is focused;
22562
+ // pull / push / fetch are the next obvious actions.
22563
+ //
22564
+ // Note: `U` and `P` currently operate on the CURRENT branch, not the
22565
+ // cursored one. Task #5 will extend them to act on the cursored row;
22566
+ // until then the labels read as "current-branch ops" by virtue of
22567
+ // matching the workflow descriptions.
22011
22568
  return {
22012
- contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
22569
+ contextual: [
22570
+ '↑/↓ branches', '←/→ tab', 'enter checkout',
22571
+ 'F fetch', 'U pull', 'P push',
22572
+ 'D delete', 'R rename', 'u upstream',
22573
+ ],
22013
22574
  global: NORMAL_GLOBAL_HINTS,
22014
22575
  };
22015
22576
  }
@@ -22704,10 +23265,17 @@ function withPoppedView(state) {
22704
23265
  * in a clean slate — the mental equivalent of a fresh `coco ui`
22705
23266
  * launched against the submodule's working dir.
22706
23267
  *
22707
- * Carry-over preferences (sidebar tab, branch / tag sort, palette
22708
- * recents, inspector tab, diff view mode) are intentionally left
22709
- * untouched. They're user-level choices that should persist across
22710
- * frames, the same way they persist across view pushes today.
23268
+ * Sidebar tab + branch / tag sort are also captured into the return
23269
+ * snapshot (#995) so popping back restores the parent's choices
23270
+ * instead of letting the submodule's tab/sort bleed across the
23271
+ * boundary. The values on the *new* frame are left as-is (carried
23272
+ * over from the parent) — the load effect in app.ts re-reads
23273
+ * persistence keyed on the submodule's workdir and dispatches a
23274
+ * restore if the user has a submodule-specific saved preference.
23275
+ *
23276
+ * Other preferences (palette recents, inspector tab, diff view mode)
23277
+ * stay global by design — the user's preference shouldn't reset when
23278
+ * they cross a submodule boundary.
22711
23279
  *
22712
23280
  * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
22713
23281
  * outside the reducer in `app.ts`'s parallel ref structure — this
@@ -22724,6 +23292,10 @@ function withPushedRepoFrame(state, payload) {
22724
23292
  selectedFileIndex: state.selectedFileIndex,
22725
23293
  selectedSubmoduleIndex: state.selectedSubmoduleIndex,
22726
23294
  filter: state.filter,
23295
+ sidebarTab: state.sidebarTab,
23296
+ userSidebarTab: state.userSidebarTab,
23297
+ branchSort: state.branchSort,
23298
+ tagSort: state.tagSort,
22727
23299
  },
22728
23300
  };
22729
23301
  return {
@@ -22776,6 +23348,15 @@ function withPoppedRepoFrame(state) {
22776
23348
  filter: ret.filter,
22777
23349
  filterMode: false,
22778
23350
  pendingCommitFocused: false,
23351
+ // #995 — restore sidebar tab + sort preferences from the captured
23352
+ // parentReturn. Without this, the submodule's tab / sort choice
23353
+ // bleeds back into the parent after pop: the user picks 'tags' in
23354
+ // a vendored submodule, pops back to the parent, and finds the
23355
+ // parent's previously-selected 'branches' tab quietly replaced.
23356
+ sidebarTab: ret.sidebarTab,
23357
+ userSidebarTab: ret.userSidebarTab,
23358
+ branchSort: ret.branchSort,
23359
+ tagSort: ret.tagSort,
22779
23360
  pendingKey: undefined,
22780
23361
  pendingConfirmationId: undefined,
22781
23362
  pendingConfirmationPayload: undefined,
@@ -23551,6 +24132,17 @@ function applyLogInkAction(state, action) {
23551
24132
  statusLoading: !action.value ? undefined : (action.loading ? true : undefined),
23552
24133
  pendingKey: undefined,
23553
24134
  };
24135
+ case 'setPendingPullRequestBodyDraft':
24136
+ // PR-body draft tracker (#881 phase 4). Set true while
24137
+ // `startCreatePullRequest` is awaiting the changelog-based
24138
+ // body generation; gates the Esc cancel binding in the input
24139
+ // handler so pressing Esc during the wait skips opening the
24140
+ // follow-up prompt instead of falling through to global Esc.
24141
+ return {
24142
+ ...state,
24143
+ pendingPullRequestBodyDraft: action.value || undefined,
24144
+ pendingKey: undefined,
24145
+ };
23554
24146
  case 'setWorkflowAction':
23555
24147
  return {
23556
24148
  ...state,
@@ -23794,6 +24386,7 @@ function applyLogInkAction(state, action) {
23794
24386
  plan: action.plan,
23795
24387
  planContext: action.planContext,
23796
24388
  scrollOffset: 0,
24389
+ fallback: action.fallback,
23797
24390
  },
23798
24391
  pendingKey: undefined,
23799
24392
  };
@@ -24570,6 +25163,36 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24570
25163
  }
24571
25164
  return [];
24572
25165
  }
25166
+ // Cancel in-flight AI commit draft (#881 phase 3). When the compose
25167
+ // surface is mid-stream (loading === true), Esc aborts the LLM call
25168
+ // and the runtime handler cleans up (clear loading, clear preview,
25169
+ // status line shows "AI draft cancelled."). Sits above the editing
25170
+ // / view handlers so the cancel keystroke can't fall through to
25171
+ // "leave compose" or anything else.
25172
+ //
25173
+ // Loading and editing are mutually exclusive in practice (the user
25174
+ // can't type while the AI is generating), but the order here makes
25175
+ // the precedence explicit if that ever changes.
25176
+ if (state.activeView === 'compose' && state.commitCompose.loading && key.escape) {
25177
+ return [{ type: 'cancelAiCommitDraft' }];
25178
+ }
25179
+ // Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
25180
+ // kicks off a changelog-based draft that runs for 5-15 seconds
25181
+ // before the input prompt opens. While the draft is pending, Esc
25182
+ // tells the runtime to skip the prompt and surface a "cancelled"
25183
+ // status. Unlike the compose cancel above, this is a *soft* cancel
25184
+ // — the background LLM call still completes, but its result is
25185
+ // discarded. Acceptable trade-off for now; deeper signal threading
25186
+ // through `changelogHandler` lands in a follow-up if real cancel
25187
+ // becomes a request.
25188
+ //
25189
+ // Sits unconditionally on the global Esc check (no `activeView`
25190
+ // gate) because the draft can be initiated from any view via the
25191
+ // palette `C` binding; Esc must work wherever the user is when
25192
+ // they decide to bail.
25193
+ if (state.pendingPullRequestBodyDraft && key.escape) {
25194
+ return [{ type: 'cancelPullRequestBodyDraft' }];
25195
+ }
24573
25196
  if (state.commitCompose.editing) {
24574
25197
  if (key.escape) {
24575
25198
  return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
@@ -26438,6 +27061,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26438
27061
  events.push({ type: 'createManualCommit' });
26439
27062
  return events;
26440
27063
  }
27064
+ // Context-sensitive per-branch variants of F / U / P. When the
27065
+ // user has the branches sidebar / view focused with at least one
27066
+ // branch, F / U / P should act on the cursored row, not on the
27067
+ // current branch. This intercept fires BEFORE the generic
27068
+ // workflow-by-key lookup below so the global *-current-branch
27069
+ // variants don't shadow the contextual ones.
27070
+ //
27071
+ // Outside the branches context, the generic lookup runs and the
27072
+ // F / U / P keys hit the global `fetch-remotes` / `pull-current-branch`
27073
+ // / `push-current-branch` workflows as before.
27074
+ if (isBranchActionTarget(state) && context.branchCount) {
27075
+ if (inputValue === 'F') {
27076
+ return [{ type: 'runWorkflowAction', id: 'fetch-selected-branch' }];
27077
+ }
27078
+ if (inputValue === 'U') {
27079
+ return [{ type: 'runWorkflowAction', id: 'pull-selected-branch' }];
27080
+ }
27081
+ if (inputValue === 'P') {
27082
+ return [{ type: 'runWorkflowAction', id: 'push-selected-branch' }];
27083
+ }
27084
+ }
26441
27085
  const workflowAction = getLogInkWorkflowActionByKey(inputValue);
26442
27086
  if (workflowAction?.requiresConfirmation) {
26443
27087
  return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
@@ -26565,17 +27209,24 @@ function formatRemainingWorktreeHint(unstaged, untracked) {
26565
27209
  *
26566
27210
  * When the worktree is clean post-apply:
26567
27211
  * "Created N commits — press gh to view them in history. Worktree is clean."
27212
+ *
27213
+ * When `fallback` is set, the planner exhausted its retry budget and
27214
+ * the apply landed the single-group fallback plan instead of a real
27215
+ * multi-group split. Prefix the message so the user knows the result
27216
+ * isn't a true LLM split — they may want to re-roll with a different
27217
+ * model, or accept the combined commit as-is.
26568
27218
  */
26569
- function formatSplitApplySuccess(commitCount, unstaged, untracked) {
27219
+ function formatSplitApplySuccess(commitCount, unstaged, untracked, fallback) {
26570
27220
  const created = commitCount === 1
26571
27221
  ? 'Created 1 commit'
26572
27222
  : `Created ${commitCount} commits`;
26573
27223
  const navCue = `${created} — press gh to view them in history.`;
26574
27224
  const remainingHint = formatRemainingWorktreeHint(unstaged, untracked);
26575
- if (!remainingHint) {
26576
- return `${navCue} Worktree is clean.`;
27225
+ const tail = remainingHint ? ` ${remainingHint}` : ' Worktree is clean.';
27226
+ if (fallback) {
27227
+ return `Split planner fallback applied (combined commit) — ${fallback.reason}. ${navCue}${tail}`;
26577
27228
  }
26578
- return `${navCue} ${remainingHint}`;
27229
+ return `${navCue}${tail}`;
26579
27230
  }
26580
27231
 
26581
27232
  /**
@@ -27540,6 +28191,106 @@ function pushCurrentBranch(git) {
27540
28191
  function setUpstream(git, localBranch, upstreamBranch) {
27541
28192
  return runAction$5(() => git.raw(['branch', '--set-upstream-to', upstreamBranch, localBranch]), `Set ${localBranch} upstream to ${upstreamBranch}`);
27542
28193
  }
28194
+ /**
28195
+ * Push an arbitrary local branch (need not be the current branch) to
28196
+ * its remote. Refuses when the branch has no upstream and no remote
28197
+ * defaulting is configured — that branch needs a `git push -u …` from
28198
+ * the shell first.
28199
+ *
28200
+ * Pairs with `pushCurrentBranch` (no-arg variant); the workstation
28201
+ * dispatcher picks one or the other based on where the cursor is.
28202
+ */
28203
+ function pushBranch(git, branch) {
28204
+ if (branch.type !== 'local') {
28205
+ return Promise.resolve({
28206
+ ok: false,
28207
+ message: 'Only local branches can be pushed.',
28208
+ });
28209
+ }
28210
+ if (!branch.upstream || !branch.remote) {
28211
+ return Promise.resolve({
28212
+ ok: false,
28213
+ message: `${branch.shortName} has no upstream — checkout the branch and run \`git push -u <remote> ${branch.shortName}\` first.`,
28214
+ });
28215
+ }
28216
+ return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
28217
+ }
28218
+ /**
28219
+ * Fetch the cursored branch's upstream from its remote. Side-effect
28220
+ * free on the working tree — just updates the remote-tracking ref.
28221
+ * Works for any branch with an upstream regardless of checkout state.
28222
+ *
28223
+ * Falls back to a clean error when the branch has no upstream
28224
+ * configured (`git fetch <remote> <name>` would assume an unrelated
28225
+ * default refspec and surprise the user).
28226
+ */
28227
+ function fetchBranch(git, branch) {
28228
+ if (branch.type !== 'local') {
28229
+ return Promise.resolve({
28230
+ ok: false,
28231
+ message: 'Only local branches can be fetched per-branch — use F to fetch all remotes.',
28232
+ });
28233
+ }
28234
+ if (!branch.upstream || !branch.remote) {
28235
+ return Promise.resolve({
28236
+ ok: false,
28237
+ message: `${branch.shortName} has no upstream — nothing to fetch.`,
28238
+ });
28239
+ }
28240
+ // `branch.upstream` is the short form (e.g. `origin/main`); the
28241
+ // ref name after the remote prefix is what fetch wants as the
28242
+ // refspec source. For a remote `origin` and upstream `origin/main`
28243
+ // we run `git fetch origin main`.
28244
+ const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
28245
+ ? branch.upstream.slice(branch.remote.length + 1)
28246
+ : branch.upstream;
28247
+ return runAction$5(() => git.raw(['fetch', branch.remote, upstreamRef]), `Fetched ${branch.upstream}`);
28248
+ }
28249
+ /**
28250
+ * Pull the cursored branch. Branches into two paths based on whether
28251
+ * the branch is currently checked out:
28252
+ *
28253
+ * - **Current branch**: defer to `pullCurrentBranch` (standard
28254
+ * `git pull --ff-only`).
28255
+ * - **Non-current branch**: use the refspec form
28256
+ * `git fetch <remote> <branch>:<branch>` which advances the local
28257
+ * ref to match the remote ref ONLY if the update is fast-forward.
28258
+ * Returns non-zero on non-FF without touching the working tree.
28259
+ * Diverged branches need a checkout + `pull --rebase` from the
28260
+ * user; we refuse rather than try to do that for them.
28261
+ *
28262
+ * `currentBranchName` lets the dispatcher compare without re-querying
28263
+ * git — it already has the value in `context.branches.currentBranch`.
28264
+ */
28265
+ function pullBranch(git, branch, currentBranchName) {
28266
+ if (branch.type !== 'local') {
28267
+ return Promise.resolve({
28268
+ ok: false,
28269
+ message: 'Only local branches can be pulled.',
28270
+ });
28271
+ }
28272
+ if (!branch.upstream || !branch.remote) {
28273
+ return Promise.resolve({
28274
+ ok: false,
28275
+ message: `${branch.shortName} has no upstream — nothing to pull.`,
28276
+ });
28277
+ }
28278
+ // Current branch — defer to the in-place workflow.
28279
+ if (branch.shortName === currentBranchName) {
28280
+ return pullCurrentBranch(git);
28281
+ }
28282
+ // Non-current branch — refspec-based fast-forward refusing non-FF.
28283
+ // `branch.upstream` is `<remote>/<ref>`; strip the remote prefix to
28284
+ // get the upstream ref name to fetch.
28285
+ const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
28286
+ ? branch.upstream.slice(branch.remote.length + 1)
28287
+ : branch.upstream;
28288
+ return runAction$5(() => git.raw([
28289
+ 'fetch',
28290
+ branch.remote,
28291
+ `${upstreamRef}:${branch.shortName}`,
28292
+ ]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
28293
+ }
27543
28294
 
27544
28295
  async function runAction$4(action, successMessage) {
27545
28296
  try {
@@ -29063,29 +29814,81 @@ function formatBranchDivergence(branch, options = {}) {
29063
29814
  parts.push(`↓${branch.behind}`);
29064
29815
  return `${parts.join(' ')} ${branch.upstream}`;
29065
29816
  }
29066
- /**
29067
- * Single-cell marker shown to the left of a branch name in lists.
29068
- *
29069
- * - `*` — current branch (regardless of remote state)
29070
- * - `◌` no upstream
29071
- * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
29072
- * - `↕` has upstream + diverged (any non-zero ahead/behind)
29073
- * - ` ` fallback / no info
29074
- *
29075
- * ASCII fallbacks (legible without box-drawing/arrow glyphs):
29076
- * - `?` for "no upstream", `=` for synced, `~` for diverged.
29077
- */
29817
+ function formatUpstreamAheadBanner(branch, options = {}) {
29818
+ if (!branch?.upstream || branch.behind <= 0) {
29819
+ return undefined;
29820
+ }
29821
+ const sep = options.ascii ? '.' : '·';
29822
+ if (branch.ahead > 0) {
29823
+ // Diverged local has work too, fast-forward pull is impossible.
29824
+ // Suggest pull --rebase as the cleaner-history default; users who
29825
+ // prefer merge can do that themselves.
29826
+ const symbols = options.ascii
29827
+ ? `+${branch.ahead} -${branch.behind}`
29828
+ : `↑${branch.ahead} ↓${branch.behind}`;
29829
+ return `${symbols} diverged from ${branch.upstream} ${sep} F fetch ${sep} U pull --rebase`;
29830
+ }
29831
+ // Behind-only — fast-forward pull works.
29832
+ const arrow = options.ascii ? 'v' : '↓';
29833
+ const noun = branch.behind === 1 ? 'commit' : 'commits';
29834
+ return `${arrow} ${branch.behind} ${noun} behind ${branch.upstream} ${sep} F fetch ${sep} U pull`;
29835
+ }
29078
29836
  function branchRowMarker(branch, options = {}) {
29079
- if (branch.current)
29080
- return '*';
29081
- if (!branch.upstream)
29082
- return options.ascii ? '?' : '◌';
29837
+ if (branch.current) {
29838
+ return { glyph: '*', kind: 'head' };
29839
+ }
29840
+ if (!branch.upstream) {
29841
+ return { glyph: options.ascii ? '?' : '◌', kind: 'no-upstream' };
29842
+ }
29083
29843
  const ahead = branch.ahead ?? 0;
29084
29844
  const behind = branch.behind ?? 0;
29085
29845
  if (ahead === 0 && behind === 0) {
29086
- return options.ascii ? '=' : '≡';
29846
+ return { glyph: options.ascii ? '=' : '≡', kind: 'synced' };
29847
+ }
29848
+ if (ahead > 0 && behind > 0) {
29849
+ return { glyph: options.ascii ? '~' : '⇅', kind: 'diverged' };
29850
+ }
29851
+ if (behind > 0) {
29852
+ return { glyph: options.ascii ? 'v' : '↓', kind: 'behind' };
29853
+ }
29854
+ // ahead > 0 (the only remaining case after the guards above)
29855
+ return { glyph: options.ascii ? '^' : '↑', kind: 'ahead' };
29856
+ }
29857
+ /**
29858
+ * Theme-aware colour picker for a `BranchRowMarker.kind`.
29859
+ *
29860
+ * Reuses the existing chip / banner colour semantic so the workstation
29861
+ * speaks one visual language across history (chips, "behind upstream"
29862
+ * banner) and the branches list:
29863
+ *
29864
+ * - `head` → success green (matches HEAD chip)
29865
+ * - `behind` → warning yellow (matches "behind upstream" banner)
29866
+ * - `diverged` → warning yellow (same: action needed inbound)
29867
+ * - `ahead` → info blue (you have work to push)
29868
+ * - `synced` → undefined (neutral; inherit row's existing dim)
29869
+ * - `no-upstream` → undefined (neutral; same)
29870
+ *
29871
+ * Returns `undefined` under `noColor` / `ascii` for the muted cases so
29872
+ * the row renderer skips the colour wrap entirely; the glyph alone
29873
+ * carries the meaning.
29874
+ */
29875
+ function getBranchRowMarkerColor(kind, theme) {
29876
+ if (theme.noColor)
29877
+ return undefined;
29878
+ switch (kind) {
29879
+ case 'head':
29880
+ return theme.colors.success;
29881
+ case 'behind':
29882
+ case 'diverged':
29883
+ return theme.colors.warning;
29884
+ case 'ahead':
29885
+ return theme.colors.info;
29886
+ case 'synced':
29887
+ case 'no-upstream':
29888
+ return undefined;
29889
+ default:
29890
+ return undefined;
29087
29891
  }
29088
- return options.ascii ? '~' : '↕';
29089
29892
  }
29090
29893
  /**
29091
29894
  * Compact, human-friendly relative timestamp for the branch row.
@@ -29693,7 +30496,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
29693
30496
  ];
29694
30497
  return [
29695
30498
  ...headerRows,
29696
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
30499
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii }).glyph} ${branch.shortName}`, 'tab-branches', visibleListCount),
29697
30500
  ];
29698
30501
  }
29699
30502
  if (tab === 'tags') {
@@ -30156,21 +30959,22 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
30156
30959
  const isSelected = index === selected;
30157
30960
  const cursor = isSelected ? '>' : ' ';
30158
30961
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
30962
+ const markerColor = getBranchRowMarkerColor(marker.kind, theme);
30159
30963
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
30160
30964
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
30161
30965
  // Split the row into spans so the timestamp stays dim even on the
30162
- // currently-selected (bold) row. The leading marker + name keep
30163
- // their per-window-derived column widths; the timestamp is
30164
- // right-padded so the divergence column stays aligned across rows.
30966
+ // currently-selected (bold) row, and the sync-state marker keeps
30967
+ // its own colour even when the surrounding row text is dimmed.
30165
30968
  const namePadded = truncateCells(branch.shortName, nameColWidth).padEnd(nameColWidth);
30166
30969
  const timestampPadded = lastTouched.padEnd(8);
30167
30970
  const lineDim = !isSelected && !branch.current;
30168
- const head = `${cursor} ${marker} ${namePadded} `;
30971
+ const cursorAndPad = `${cursor} `;
30972
+ const trailingName = ` ${namePadded} `;
30169
30973
  const trailingDivergence = divergence ? ` ${divergence}` : '';
30170
30974
  // Truncate the assembled line to the actual panel width so a
30171
30975
  // narrow inspector / sidebar focus doesn't push branch rows
30172
30976
  // onto a second visual line (#830).
30173
- const fullText = `${head}${timestampPadded}${trailingDivergence}`;
30977
+ const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
30174
30978
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
30175
30979
  // If truncation chopped into the timestamp/divergence portion,
30176
30980
  // fall back to a single Text to keep the visible width honest.
@@ -30185,7 +30989,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
30185
30989
  key: `branch-${index}`,
30186
30990
  bold: isSelected,
30187
30991
  dimColor: lineDim,
30188
- }, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
30992
+ }, cursorAndPad,
30993
+ // The marker carries the sync-state colour; an explicit
30994
+ // `dimColor: false` on this span keeps the colour bright even
30995
+ // when the surrounding row is dim (other branches in the list
30996
+ // dim out under the existing `lineDim` rule). The synced /
30997
+ // no-upstream kinds return undefined from
30998
+ // `getBranchRowMarkerColor`, so those markers inherit the
30999
+ // row's dim and read as quiet chrome.
31000
+ h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
30189
31001
  });
30190
31002
  return h(Box, {
30191
31003
  borderColor: focusBorderColor(theme, focused),
@@ -30312,6 +31124,97 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
30312
31124
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
30313
31125
  }
30314
31126
 
31127
+ /**
31128
+ * Streaming-preview helper (#881 phase 2). Turns the raw accumulated
31129
+ * text from an in-flight LLM stream into the last N visual lines that
31130
+ * fit a given panel width, plus a flag telling the renderer whether
31131
+ * earlier content was elided.
31132
+ *
31133
+ * Why a chrome helper instead of inlining the math in the compose
31134
+ * surface: the same shape is going to be reused by PR-body and review
31135
+ * streaming once those surfaces opt in. The visual line math (wrap to
31136
+ * width, count from the bottom, mark truncation) doesn't belong on the
31137
+ * surface itself.
31138
+ *
31139
+ * No JSX / no Ink here — chrome modules stay framework-agnostic and
31140
+ * return data the surface can hand to its own `h(Text, ...)` calls.
31141
+ */
31142
+ /**
31143
+ * Default last-N visible visual lines. Tuned for compose where the
31144
+ * panel already shows summary + body + loading line, so the preview
31145
+ * can't take more vertical space without pushing the state-line off
31146
+ * the bottom of short terminals. 6 lines is roughly two short
31147
+ * commit-body paragraphs — enough to feel like content is flowing,
31148
+ * not so much that the user loses sight of the surrounding chrome.
31149
+ */
31150
+ const DEFAULT_STREAMING_PREVIEW_LINES = 6;
31151
+ /**
31152
+ * Marker prefixed to the first visible line when earlier content was
31153
+ * elided. Chrome theme picks ASCII vs Unicode at render time; this
31154
+ * module returns both so surfaces don't need to import the theme.
31155
+ */
31156
+ const STREAMING_PREVIEW_TRUNCATE_GLYPH = '…';
31157
+ const STREAMING_PREVIEW_TRUNCATE_ASCII = '...';
31158
+ /**
31159
+ * Compute the visible preview window for a streaming buffer.
31160
+ *
31161
+ * The buffer is split on newlines (preserving blank lines so paragraph
31162
+ * spacing stays visible), each source line is hard-wrapped to `width`,
31163
+ * and the trailing `maxLines` wrapped lines are returned. When the
31164
+ * total wrapped line count exceeds `maxLines`, `truncated` is true so
31165
+ * the renderer can prefix the first line with an ellipsis marker.
31166
+ *
31167
+ * Whitespace-only / empty input returns `{ lines: [], truncated: false }`
31168
+ * so renderers can branch on `lines.length === 0` to skip rendering
31169
+ * entirely during the brief window between dispatching `setLoading`
31170
+ * and the first chunk arriving.
31171
+ *
31172
+ * Width math mirrors the compose surface's body wrap (`width - 6` for
31173
+ * border + paddingX + 2-space indent budget); callers pass the width
31174
+ * they intend to use and this helper assumes it's the wrap budget,
31175
+ * not the panel width.
31176
+ */
31177
+ function formatStreamingPreview(accumulated, width, maxLines = DEFAULT_STREAMING_PREVIEW_LINES) {
31178
+ if (!accumulated) {
31179
+ return { lines: [], truncated: false };
31180
+ }
31181
+ const trimmed = accumulated.replace(/\s+$/u, '');
31182
+ if (!trimmed) {
31183
+ return { lines: [], truncated: false };
31184
+ }
31185
+ // Wrap each source line. Empty source lines must survive the wrap so
31186
+ // a stream like "A\n\nB" reads as two paragraphs separated by a blank
31187
+ // row rather than collapsing into "A B".
31188
+ const wrapWidth = Math.max(8, width);
31189
+ const wrapped = [];
31190
+ for (const line of trimmed.split('\n')) {
31191
+ if (line === '') {
31192
+ wrapped.push('');
31193
+ continue;
31194
+ }
31195
+ for (const segment of wrapCells(line, wrapWidth)) {
31196
+ wrapped.push(segment);
31197
+ }
31198
+ }
31199
+ const budget = Math.max(1, maxLines);
31200
+ if (wrapped.length <= budget) {
31201
+ return { lines: wrapped, truncated: false };
31202
+ }
31203
+ return {
31204
+ lines: wrapped.slice(wrapped.length - budget),
31205
+ truncated: true,
31206
+ };
31207
+ }
31208
+ /**
31209
+ * Resolve the truncation marker for the current theme. Pure helper so
31210
+ * the surface can render a single-character glyph in colour terminals
31211
+ * and the ASCII fallback when `theme.ascii` is on. Centralised here so
31212
+ * future surfaces opting into streaming use the same glyph.
31213
+ */
31214
+ function streamingPreviewTruncateMarker(ascii) {
31215
+ return ascii ? STREAMING_PREVIEW_TRUNCATE_ASCII : STREAMING_PREVIEW_TRUNCATE_GLYPH;
31216
+ }
31217
+
30315
31218
  /**
30316
31219
  * Compose surface — the in-TUI commit-message composer. Combines a
30317
31220
  * summary line, a body field, and a state-line footer; an inline
@@ -30321,6 +31224,33 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
30321
31224
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
30322
31225
  * of #890. No behavior change.
30323
31226
  */
31227
+ /**
31228
+ * Render the streaming-preview block — the trailing lines of the
31229
+ * in-flight LLM stream that sit below the loading spinner. Pure
31230
+ * formatting; the wrap math + truncation flag live in the
31231
+ * `streamingPreview` chrome helper so other surfaces (PR body,
31232
+ * review) can reuse them later.
31233
+ *
31234
+ * Returns an empty array when no preview text is present (the loader
31235
+ * just shows the spinner) so the caller's spread doesn't insert blank
31236
+ * rows that would shift the state-line.
31237
+ */
31238
+ function renderStreamingPreviewLines(h, components, preview, width, theme) {
31239
+ const { Text } = components;
31240
+ const view = formatStreamingPreview(preview, width);
31241
+ if (view.lines.length === 0)
31242
+ return [];
31243
+ const marker = view.truncated ? streamingPreviewTruncateMarker(theme.ascii) : '';
31244
+ return view.lines.map((line, index) => {
31245
+ // Prefix the first line with the truncation marker when earlier
31246
+ // content was elided. Subsequent lines render unprefixed.
31247
+ const prefix = index === 0 && marker ? `${marker} ` : ' ';
31248
+ return h(Text, {
31249
+ key: `compose-stream-${index}`,
31250
+ dimColor: true,
31251
+ }, `${prefix}${line}`);
31252
+ });
31253
+ }
30324
31254
  function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
30325
31255
  const { Box, Text } = components;
30326
31256
  const compose = state.commitCompose;
@@ -30344,9 +31274,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
30344
31274
  : ['<empty>'];
30345
31275
  const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
30346
31276
  );
31277
+ // State-line cycles through three modes (#881 phase 3 added the
31278
+ // loading variant): editing copy when the user is typing, cancel
31279
+ // hint when an AI draft is generating, default guidance otherwise.
31280
+ // The cancel hint also covers the streaming preview window — same
31281
+ // keystroke (Esc) aborts whether or not the preview is visible.
30347
31282
  const stateLine = compose.editing
30348
31283
  ? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
30349
- : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
31284
+ : compose.loading
31285
+ ? 'Generating AI draft — press Esc to cancel.'
31286
+ : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
30350
31287
  const hasStagedFiles = (worktree?.files || [])
30351
31288
  .some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
30352
31289
  // Staged file list is rendered in the right Worktree panel
@@ -30393,6 +31330,13 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
30393
31330
  }, theme.ascii
30394
31331
  ? `[${pickSpinnerFrame(spinnerFrame).replace(/[^a-zA-Z0-9 ]/g, '.')}] Generating AI commit draft (this can take a moment)`
30395
31332
  : `${pickSpinnerFrame(spinnerFrame)} Generating AI commit draft… (this can take a moment)`),
31333
+ // Streaming preview (#881 phase 2). Renders the trailing visual
31334
+ // lines of the in-flight LLM stream below the loader so the user
31335
+ // sees content building up instead of an opaque spinner. Empty
31336
+ // before the first chunk arrives; the preview helper returns an
31337
+ // empty `lines` array in that window so we skip the block
31338
+ // entirely.
31339
+ ...renderStreamingPreviewLines(h, components, compose.streamingPreview, bodyTextWidth, theme),
30396
31340
  ]
30397
31341
  : []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncateCells(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
30398
31342
  key: `compose-detail-${index}`,
@@ -32197,6 +33141,25 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
32197
33141
  paddingX: 1,
32198
33142
  width,
32199
33143
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${title} | ${graphMode} | ${loadState}`)),
33144
+ // Upstream-ahead banner. Surfaces "the remote has work you don't"
33145
+ // for the current branch — distinct from the chip work in 0.52.0
33146
+ // which colours remote refs IN the row set. On a behind branch the
33147
+ // upstream commits aren't reachable from local HEAD, so the chips
33148
+ // alone can't signal "fetch / pull needed." This single line does.
33149
+ //
33150
+ // Two wording variants (behind-only vs diverged) live in the
33151
+ // helper; render is identical aside from the formatted string.
33152
+ // Warning yellow = same semantic as the remote-tracking chip kind.
33153
+ ...((() => {
33154
+ const currentBranchRef = context.branches?.localBranches.find((branch) => branch.current);
33155
+ const banner = formatUpstreamAheadBanner(currentBranchRef, { ascii: theme.ascii });
33156
+ if (!banner)
33157
+ return [];
33158
+ return [h(Text, {
33159
+ key: 'upstream-ahead-banner',
33160
+ color: theme.noColor ? undefined : theme.colors.warning,
33161
+ }, banner)];
33162
+ })()),
32200
33163
  // Server-side filter indicator (#776). Only rendered when the user
32201
33164
  // has an active path:/author: prefix; clears when they Ctrl+U.
32202
33165
  ...(state.historyFetchArgs
@@ -35247,9 +36210,18 @@ function LogInkApp(deps) {
35247
36210
  // Wrappers that delegate to the active frame's runtime entry so the
35248
36211
  // existing call sites stay byte-identical. Support both function-
35249
36212
  // updater and value-updater forms (the codebase uses both).
35250
- const setContext = React.useCallback((arg) => {
36213
+ //
36214
+ // `targetDepth` (#994) routes the write to a specific frame instead
36215
+ // of the currently-active one. Loaders that capture the depth at
36216
+ // issue-time and pass it here are robust against frame-stack
36217
+ // mutations (push / pop) that happen while the load is in flight —
36218
+ // the write lands on the frame that issued it, or silently drops
36219
+ // if that frame has been popped (`updateRepoFrameRuntime` no-ops on
36220
+ // out-of-range indices). Without the tag, an in-flight refresh on
36221
+ // the parent would clobber a freshly-pushed submodule frame.
36222
+ const setContext = React.useCallback((arg, targetDepth) => {
35251
36223
  setRuntimes((prev) => {
35252
- const depth = prev.length - 1;
36224
+ const depth = targetDepth ?? prev.length - 1;
35253
36225
  if (depth < 0)
35254
36226
  return prev;
35255
36227
  return updateRepoFrameRuntime(prev, depth, (frame) => ({
@@ -35260,9 +36232,9 @@ function LogInkApp(deps) {
35260
36232
  }));
35261
36233
  });
35262
36234
  }, []);
35263
- const setContextStatus = React.useCallback((arg) => {
36235
+ const setContextStatus = React.useCallback((arg, targetDepth) => {
35264
36236
  setRuntimes((prev) => {
35265
- const depth = prev.length - 1;
36237
+ const depth = targetDepth ?? prev.length - 1;
35266
36238
  if (depth < 0)
35267
36239
  return prev;
35268
36240
  return updateRepoFrameRuntime(prev, depth, (frame) => ({
@@ -35588,28 +36560,39 @@ function LogInkApp(deps) {
35588
36560
  // (stale-while-revalidate) and quietly swap it in once the new fetch
35589
36561
  // resolves — avoids the every-second flicker the watcher would
35590
36562
  // otherwise produce on busy repos.
36563
+ //
36564
+ // #994 — capture the depth this refresh was issued from BEFORE
36565
+ // the await. The callback closure also captured `git` from the
36566
+ // same render, so they're consistent: when the user drills into
36567
+ // a submodule mid-await, the resolved data still lands on the
36568
+ // parent frame (the one whose `git` was used for the fetch),
36569
+ // not on the freshly-pushed submodule frame.
36570
+ const issuedAtDepth = runtimes.length - 1;
35591
36571
  if (!options.silent) {
35592
36572
  dispatch({ type: 'setStatus', value: 'refreshing repository context' });
35593
- setContextStatus(createLogInkContextStatus('loading'));
36573
+ setContextStatus(createLogInkContextStatus('loading'), issuedAtDepth);
35594
36574
  }
35595
36575
  const next = await loadLogInkContext(git);
35596
- setContext(next);
35597
- setContextStatus(createLogInkContextStatus('ready'));
36576
+ setContext(next, issuedAtDepth);
36577
+ setContextStatus(createLogInkContextStatus('ready'), issuedAtDepth);
35598
36578
  if (!options.silent) {
35599
36579
  dispatch({ type: 'setStatus', value: 'repository context refreshed' });
35600
36580
  }
35601
- }, [dispatch, git]);
36581
+ }, [dispatch, git, runtimes.length, setContext, setContextStatus]);
35602
36582
  const refreshWorktreeContext = React.useCallback(async (options = {}) => {
36583
+ // #994 — same frame-tagging as refreshContext above. Worktree
36584
+ // loads are usually fast but still race-prone on slow disks.
36585
+ const issuedAtDepth = runtimes.length - 1;
35603
36586
  if (!options.silent) {
35604
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
36587
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'), issuedAtDepth);
35605
36588
  }
35606
36589
  const worktree = await safe(getWorktreeOverview(git));
35607
36590
  setContext((current) => ({
35608
36591
  ...current,
35609
36592
  worktree,
35610
- }));
35611
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
35612
- }, [git]);
36593
+ }), issuedAtDepth);
36594
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
36595
+ }, [git, runtimes.length, setContext, setContextStatus]);
35613
36596
  // Live refresh: watch .git metadata + the working tree root and reload
35614
36597
  // context when something changes outside the TUI (editor save, external
35615
36598
  // git commands, branch switch in another terminal). Best-effort — the
@@ -35834,6 +36817,11 @@ function LogInkApp(deps) {
35834
36817
  const contextStatusRef = React.useRef(contextStatus);
35835
36818
  contextStatusRef.current = contextStatus;
35836
36819
  React.useEffect(() => {
36820
+ // #994 — capture the depth this boot load is being issued for.
36821
+ // The git instance in the closure is bound to this frame; tagged
36822
+ // writes ensure resolved values land on the correct runtime entry
36823
+ // even if a subsequent push/pop changes the active frame mid-load.
36824
+ const issuedAtDepth = runtimes.length - 1;
35837
36825
  let active = true;
35838
36826
  loadLogInkContextEntries(git).forEach(({ key, load }) => {
35839
36827
  if (contextStatusRef.current[key] === 'ready')
@@ -35845,14 +36833,14 @@ function LogInkApp(deps) {
35845
36833
  setContext((current) => ({
35846
36834
  ...current,
35847
36835
  [key]: value,
35848
- }));
35849
- setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'));
36836
+ }), issuedAtDepth);
36837
+ setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'), issuedAtDepth);
35850
36838
  });
35851
36839
  });
35852
36840
  return () => {
35853
36841
  active = false;
35854
36842
  };
35855
- }, [git]);
36843
+ }, [git, runtimes.length, setContext, setContextStatus]);
35856
36844
  // Lazy-load the full pullRequest overview (#808). Only fires when
35857
36845
  // the user actually navigates to the PR view, and only when we
35858
36846
  // don't already have data (so a workflow-triggered refresh that
@@ -35866,21 +36854,22 @@ function LogInkApp(deps) {
35866
36854
  return;
35867
36855
  if (context.pullRequest)
35868
36856
  return;
36857
+ const issuedAtDepth = runtimes.length - 1;
35869
36858
  let active = true;
35870
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
36859
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'), issuedAtDepth);
35871
36860
  void safe(getPullRequestOverview(git)).then((value) => {
35872
36861
  if (!active)
35873
36862
  return;
35874
36863
  setContext((current) => ({
35875
36864
  ...current,
35876
36865
  pullRequest: value,
35877
- }));
35878
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
36866
+ }), issuedAtDepth);
36867
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'), issuedAtDepth);
35879
36868
  });
35880
36869
  return () => {
35881
36870
  active = false;
35882
36871
  };
35883
- }, [git, state.activeView, context.pullRequest]);
36872
+ }, [git, runtimes.length, state.activeView, context.pullRequest, setContext, setContextStatus]);
35884
36873
  // Lazy-load the issue triage list (#882 phase 3, filter-aware
35885
36874
  // since phase 6). Fires on entry to the view AND on filter
35886
36875
  // preset changes (`f` cycles the preset; the dep on
@@ -35892,8 +36881,9 @@ function LogInkApp(deps) {
35892
36881
  return;
35893
36882
  if (context.issueList)
35894
36883
  return;
36884
+ const issuedAtDepth = runtimes.length - 1;
35895
36885
  let active = true;
35896
- setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
36886
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'), issuedAtDepth);
35897
36887
  const filter = issueFilterForPreset(state.selectedIssueFilter);
35898
36888
  void safe(getIssueList(git, filter)).then((value) => {
35899
36889
  if (!active)
@@ -35901,13 +36891,21 @@ function LogInkApp(deps) {
35901
36891
  setContext((current) => ({
35902
36892
  ...current,
35903
36893
  issueList: value,
35904
- }));
35905
- setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
36894
+ }), issuedAtDepth);
36895
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'), issuedAtDepth);
35906
36896
  });
35907
36897
  return () => {
35908
36898
  active = false;
35909
36899
  };
35910
- }, [git, state.activeView, context.issueList, state.selectedIssueFilter]);
36900
+ }, [
36901
+ git,
36902
+ runtimes.length,
36903
+ state.activeView,
36904
+ context.issueList,
36905
+ state.selectedIssueFilter,
36906
+ setContext,
36907
+ setContextStatus,
36908
+ ]);
35911
36909
  // Filter cycling: when the preset changes, drop the cached list
35912
36910
  // so the effect above re-fires with the new filter. Done as a
35913
36911
  // separate effect (rather than folded into the cycle reducer)
@@ -35931,8 +36929,9 @@ function LogInkApp(deps) {
35931
36929
  return;
35932
36930
  if (context.pullRequestList)
35933
36931
  return;
36932
+ const issuedAtDepth = runtimes.length - 1;
35934
36933
  let active = true;
35935
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
36934
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'), issuedAtDepth);
35936
36935
  const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
35937
36936
  void safe(getPullRequestList(git, filter)).then((value) => {
35938
36937
  if (!active)
@@ -35940,13 +36939,21 @@ function LogInkApp(deps) {
35940
36939
  setContext((current) => ({
35941
36940
  ...current,
35942
36941
  pullRequestList: value,
35943
- }));
35944
- setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
36942
+ }), issuedAtDepth);
36943
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'), issuedAtDepth);
35945
36944
  });
35946
36945
  return () => {
35947
36946
  active = false;
35948
36947
  };
35949
- }, [git, state.activeView, context.pullRequestList, state.selectedPullRequestFilter]);
36948
+ }, [
36949
+ git,
36950
+ runtimes.length,
36951
+ state.activeView,
36952
+ context.pullRequestList,
36953
+ state.selectedPullRequestFilter,
36954
+ setContext,
36955
+ setContextStatus,
36956
+ ]);
35950
36957
  React.useEffect(() => {
35951
36958
  if (state.activeView !== 'pull-request-triage')
35952
36959
  return;
@@ -35976,6 +36983,7 @@ function LogInkApp(deps) {
35976
36983
  return;
35977
36984
  if (context.issueDetailByNumber?.has(cursored.number))
35978
36985
  return;
36986
+ const issuedAtDepth = runtimes.length - 1;
35979
36987
  let active = true;
35980
36988
  const timer = setTimeout(async () => {
35981
36989
  const result = await getIssueDetail(cursored.number);
@@ -35984,17 +36992,19 @@ function LogInkApp(deps) {
35984
36992
  setContext((current) => ({
35985
36993
  ...current,
35986
36994
  issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
35987
- }));
36995
+ }), issuedAtDepth);
35988
36996
  }, DETAIL_HYDRATION_DELAY_MS);
35989
36997
  return () => {
35990
36998
  active = false;
35991
36999
  clearTimeout(timer);
35992
37000
  };
35993
37001
  }, [
37002
+ runtimes.length,
35994
37003
  state.activeView,
35995
37004
  state.selectedIssueIndex,
35996
37005
  filteredIssueList,
35997
37006
  context.issueDetailByNumber,
37007
+ setContext,
35998
37008
  ]);
35999
37009
  React.useEffect(() => {
36000
37010
  if (state.activeView !== 'pull-request-triage')
@@ -36004,6 +37014,7 @@ function LogInkApp(deps) {
36004
37014
  return;
36005
37015
  if (context.pullRequestDetailByNumber?.has(cursored.number))
36006
37016
  return;
37017
+ const issuedAtDepth = runtimes.length - 1;
36007
37018
  let active = true;
36008
37019
  const timer = setTimeout(async () => {
36009
37020
  const result = await getPullRequestDetail(cursored.number);
@@ -36012,17 +37023,19 @@ function LogInkApp(deps) {
36012
37023
  setContext((current) => ({
36013
37024
  ...current,
36014
37025
  pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
36015
- }));
37026
+ }), issuedAtDepth);
36016
37027
  }, DETAIL_HYDRATION_DELAY_MS);
36017
37028
  return () => {
36018
37029
  active = false;
36019
37030
  clearTimeout(timer);
36020
37031
  };
36021
37032
  }, [
37033
+ runtimes.length,
36022
37034
  state.activeView,
36023
37035
  state.selectedPullRequestTriageIndex,
36024
37036
  filteredPullRequestTriageList,
36025
37037
  context.pullRequestDetailByNumber,
37038
+ setContext,
36026
37039
  ]);
36027
37040
  React.useEffect(() => {
36028
37041
  let active = true;
@@ -36283,21 +37296,96 @@ function LogInkApp(deps) {
36283
37296
  state.commitCompose.body,
36284
37297
  state.commitCompose.summary,
36285
37298
  ]);
37299
+ // AbortController for the in-flight AI draft (#881 phase 3). Kept in
37300
+ // a ref rather than state because cancel is a side-effect: the input
37301
+ // handler reads `controllerRef.current?.abort()` synchronously when
37302
+ // Esc fires during a loading draft. Storing it in state would force
37303
+ // a re-render on every set, and React doesn't need to know — only
37304
+ // the imperative cancel path does. Cleared after each call settles
37305
+ // so a stale controller can't cancel a future draft.
37306
+ const aiDraftAbortRef = React.useRef(null);
36286
37307
  const runAiCommitDraft = React.useCallback(async () => {
37308
+ // Tear down any controller from a previous draft (defensive — a
37309
+ // settled call should have cleared it in the finally block, but
37310
+ // double-running would otherwise leave the first orphaned).
37311
+ aiDraftAbortRef.current?.abort();
37312
+ const controller = new AbortController();
37313
+ aiDraftAbortRef.current = controller;
36287
37314
  dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
36288
37315
  dispatch({ type: 'setStatus', value: 'generating AI commit draft', loading: true });
36289
- const result = await runCommitDraftWorkflow();
36290
- if (result.ok && result.draft) {
36291
- dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
36292
- dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
36293
- return;
37316
+ // Streaming preview (#881 phase 2). The workflow forwards this to
37317
+ // `generateCommitDraft`, which only actually streams when the
37318
+ // user opted in via `service.streaming.enabled`. The callback
37319
+ // updates `commitCompose.streamingPreview` so the compose surface
37320
+ // renders a live last-N-lines preview below the loader. The
37321
+ // reducer clears `streamingPreview` whenever loading flips off
37322
+ // (success or failure), so we don't need an explicit teardown
37323
+ // dispatch here.
37324
+ try {
37325
+ const result = await runCommitDraftWorkflow({
37326
+ git,
37327
+ signal: controller.signal,
37328
+ onStreamChunk: (_text, accumulated) => {
37329
+ // Dispatch the full accumulated text — the preview chrome
37330
+ // helper does the last-N-lines slicing at render time, so
37331
+ // re-doing the slice here would be wasted work. Per-chunk
37332
+ // dispatches are cheap; React batches them and Ink redraws
37333
+ // at its own frame cadence.
37334
+ dispatch({
37335
+ type: 'commitCompose',
37336
+ action: { type: 'setStreamingPreview', value: accumulated },
37337
+ });
37338
+ },
37339
+ });
37340
+ // Cancel path (#881 phase 3). User pressed Esc during the
37341
+ // stream; reducer drops loading + preview, status line shows
37342
+ // a neutral "cancelled" message. Skip the result / failure
37343
+ // dispatches because the user already knows what happened.
37344
+ if (result.cancelled) {
37345
+ dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
37346
+ dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
37347
+ return;
37348
+ }
37349
+ if (result.ok && result.draft) {
37350
+ dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
37351
+ dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
37352
+ return;
37353
+ }
37354
+ dispatch({
37355
+ type: 'commitCompose',
37356
+ action: { type: 'setResult', message: result.message, details: result.details },
37357
+ });
37358
+ dispatch({ type: 'setStatus', value: result.message });
36294
37359
  }
36295
- dispatch({
36296
- type: 'commitCompose',
36297
- action: { type: 'setResult', message: result.message, details: result.details },
36298
- });
36299
- dispatch({ type: 'setStatus', value: result.message });
36300
- }, [dispatch]);
37360
+ finally {
37361
+ // Clear the ref only if it still points at OUR controller — a
37362
+ // rapid second invocation could have already replaced it, in
37363
+ // which case the new controller is the one that owns cancel
37364
+ // duty now.
37365
+ if (aiDraftAbortRef.current === controller) {
37366
+ aiDraftAbortRef.current = null;
37367
+ }
37368
+ }
37369
+ }, [dispatch, git]);
37370
+ /**
37371
+ * Cancel an in-flight AI draft (#881 phase 3). Called by the input
37372
+ * handler when the user presses Esc while `commitCompose.loading`
37373
+ * is true. Idempotent — calling without an active controller is a
37374
+ * no-op rather than an error so the keystroke handler can fire
37375
+ * unconditionally during the loading window.
37376
+ *
37377
+ * `controller.abort()` propagates through
37378
+ * `executeChainStreaming`, which throws `LangChainCancelledError`,
37379
+ * which becomes `cancelled: true` on the workflow result. The
37380
+ * runAiCommitDraft promise's finally block clears the ref. The
37381
+ * resulting cleanup dispatches (clearing loading + status) happen
37382
+ * back in `runAiCommitDraft`, not here, so this function stays
37383
+ * pure-imperative and the React state updates flow through a
37384
+ * single code path.
37385
+ */
37386
+ const cancelAiCommitDraft = React.useCallback(() => {
37387
+ aiDraftAbortRef.current?.abort();
37388
+ }, []);
36301
37389
  // `C` keystroke handler — start the create-pull-request flow. Resolves
36302
37390
  // the head + base branches from the live context, runs
36303
37391
  // `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
@@ -36311,6 +37399,19 @@ function LogInkApp(deps) {
36311
37399
  // missing) we surface the failure on the status line and skip the
36312
37400
  // prompt entirely — better than opening a prompt the user can't
36313
37401
  // actually submit successfully.
37402
+ // Soft-cancel handle for the PR body draft (#881 phase 4). A mutable
37403
+ // ref rather than state because the cancel decision needs to be
37404
+ // visible synchronously inside the async workflow without forcing
37405
+ // re-renders. Owned by the in-flight invocation: the cancel callback
37406
+ // mutates `.cancelled` on the live ref; the workflow checks it after
37407
+ // `await` resolves and decides whether to open the follow-up prompt.
37408
+ //
37409
+ // The LLM call itself keeps running (no AbortSignal threaded through
37410
+ // `changelogHandler` today). The user-visible outcome — "PR draft
37411
+ // cancelled, no prompt opens" — is identical to a hard cancel, at
37412
+ // the cost of paying for the in-flight tokens. Deeper threading
37413
+ // lands in a follow-up if hard cancel becomes a request.
37414
+ const pullRequestBodyCancelRef = React.useRef(null);
36314
37415
  const startCreatePullRequest = React.useCallback(async () => {
36315
37416
  const head = context.branches?.currentBranch || context.provider?.currentBranch;
36316
37417
  if (!head) {
@@ -36339,32 +37440,61 @@ function LogInkApp(deps) {
36339
37440
  });
36340
37441
  return;
36341
37442
  }
37443
+ // Set up the cancel handle BEFORE flipping the pending flag so a
37444
+ // race between the flag-set and a synchronous Esc keystroke can't
37445
+ // leave the input handler dispatching cancel without a ref to
37446
+ // mutate. The cancel callback no-ops cleanly when the ref is null
37447
+ // (call already settled).
37448
+ const cancelHandle = { cancelled: false };
37449
+ pullRequestBodyCancelRef.current = cancelHandle;
37450
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
36342
37451
  dispatch({
36343
37452
  type: 'setStatus',
36344
- value: `generating PR body from changelog (vs ${defaultBranch})…`,
37453
+ value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
36345
37454
  loading: true,
36346
37455
  });
36347
- const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
36348
- // Fallback shape when the changelog generation fails — open the
36349
- // prompt with empty title + body rather than aborting, so the user
36350
- // can still author the PR manually. The status line surfaces why
36351
- // we couldn't pre-fill.
36352
- const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
36353
- const initialBody = body.body || '';
36354
- const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
36355
- if (!body.ok) {
36356
- dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
37456
+ try {
37457
+ const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
37458
+ // Soft-cancel check (#881 phase 4). If the user pressed Esc
37459
+ // while the workflow was awaiting, skip opening the prompt and
37460
+ // surface a neutral status. The underlying LLM call has
37461
+ // already settled its result is discarded. Hard cancel
37462
+ // (aborting the HTTP request mid-flight) is a follow-up.
37463
+ if (cancelHandle.cancelled) {
37464
+ dispatch({ type: 'setStatus', value: 'PR draft cancelled.' });
37465
+ return;
37466
+ }
37467
+ // Fallback shape when the changelog generation fails — open the
37468
+ // prompt with empty title + body rather than aborting, so the user
37469
+ // can still author the PR manually. The status line surfaces why
37470
+ // we couldn't pre-fill.
37471
+ const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
37472
+ const initialBody = body.body || '';
37473
+ const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
37474
+ if (!body.ok) {
37475
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
37476
+ }
37477
+ else {
37478
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
37479
+ }
37480
+ dispatch({
37481
+ type: 'openInputPrompt',
37482
+ kind: 'create-pr',
37483
+ label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
37484
+ initial,
37485
+ multiline: true,
37486
+ });
36357
37487
  }
36358
- else {
36359
- dispatch({ type: 'setStatus', value: 'PR body drafted review and Ctrl+D to submit.' });
37488
+ finally {
37489
+ // Clear the flag + the ref so a subsequent draft starts clean.
37490
+ // Only clear the ref if we still own it — a second invocation
37491
+ // would have already taken ownership in which case the cancel
37492
+ // duty has rolled over.
37493
+ dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
37494
+ if (pullRequestBodyCancelRef.current === cancelHandle) {
37495
+ pullRequestBodyCancelRef.current = null;
37496
+ }
36360
37497
  }
36361
- dispatch({
36362
- type: 'openInputPrompt',
36363
- kind: 'create-pr',
36364
- label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
36365
- initial,
36366
- multiline: true,
36367
- });
36368
37498
  }, [
36369
37499
  context.branches?.currentBranch,
36370
37500
  context.provider?.currentBranch,
@@ -36373,6 +37503,24 @@ function LogInkApp(deps) {
36373
37503
  context.pullRequest?.currentPullRequest,
36374
37504
  dispatch,
36375
37505
  ]);
37506
+ /**
37507
+ * Soft-cancel the in-flight PR body draft (#881 phase 4). The
37508
+ * cancel ref's `.cancelled` flag is checked after the workflow's
37509
+ * await resolves; setting it true causes the workflow to skip the
37510
+ * prompt-open and surface a neutral "cancelled" status. The LLM
37511
+ * call itself isn't aborted (no signal threaded through the
37512
+ * `changelogHandler` chain) so the user still pays for the in-flight
37513
+ * tokens. Acceptable for a 5-15s draft; hard cancel lands in a
37514
+ * follow-up if it becomes a real ask.
37515
+ *
37516
+ * Idempotent — calling without an active draft is a no-op.
37517
+ */
37518
+ const cancelPullRequestBodyDraft = React.useCallback(() => {
37519
+ const handle = pullRequestBodyCancelRef.current;
37520
+ if (!handle)
37521
+ return;
37522
+ handle.cancelled = true;
37523
+ }, []);
36376
37524
  // Copy an arbitrary string to the system clipboard. Distinct from
36377
37525
  // `yankFromActiveView` which derives the value from the current view
36378
37526
  // — this one takes the value as an explicit event payload, used by
@@ -36798,11 +37946,18 @@ function LogInkApp(deps) {
36798
37946
  type: 'setSplitPlanReady',
36799
37947
  plan: result.plan,
36800
37948
  planContext: result.planContext,
37949
+ fallback: result.fallback,
36801
37950
  });
37951
+ const readyMessage = result.fallback
37952
+ ? `Split planner exhausted retries — showing single-commit fallback. y/Enter to apply as one commit, r to re-roll, Esc to cancel.`
37953
+ : `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`;
37954
+ // Use 'info' kind for the fallback path (still actionable, just
37955
+ // not a clean win). The reducer's "warning" is the absence of
37956
+ // `success` framing — the message text itself carries the cue.
36802
37957
  dispatch({
36803
37958
  type: 'setStatus',
36804
- value: `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`,
36805
- kind: 'success',
37959
+ value: readyMessage,
37960
+ kind: result.fallback ? 'info' : 'success',
36806
37961
  });
36807
37962
  }, [context.operation, context.worktree?.stagedCount, dispatch, git]);
36808
37963
  // `y`/Enter inside the overlay — apply the previewed plan. Uses the
@@ -36844,6 +37999,7 @@ function LogInkApp(deps) {
36844
37999
  plan: splitPlan.plan,
36845
38000
  planContext: splitPlan.planContext,
36846
38001
  git,
38002
+ fallback: splitPlan.fallback,
36847
38003
  });
36848
38004
  dump.push(`workflow returned: ok=${result.ok} message="${result.message}" commitHashes=[${(result.commitHashes || []).join(', ')}]`);
36849
38005
  try {
@@ -36877,16 +38033,20 @@ function LogInkApp(deps) {
36877
38033
  return;
36878
38034
  }
36879
38035
  // Success — close the overlay, reset compose (the staged set is
36880
- // now empty since the plan committed everything), and pop the
36881
- // compose view so the user lands on whatever was beneath (usually
36882
- // status, sometimes history).
38036
+ // now empty since the plan committed everything), and route the
38037
+ // user to the history view so they see the just-landed commits
38038
+ // with the recent-commit marker firing on each row that was
38039
+ // created. Previous behavior popped compose to whatever was
38040
+ // beneath (often status — which now reads "clean worktree" and
38041
+ // gives the user no signal that anything just happened);
38042
+ // history is the natural follow-on surface.
38043
+ //
38044
+ // navigateHome nukes the rest of the stack so `<` after apply
38045
+ // doesn't walk back into the now-empty compose / status state
38046
+ // the user just left behind.
36883
38047
  dispatch({ type: 'clearSplitPlan' });
36884
38048
  dispatch({ type: 'commitCompose', action: { type: 'reset' } });
36885
- // Only pop if compose is on top — the apply could have been
36886
- // invoked from a deeper stack and we don't want to over-pop.
36887
- if (state.activeView === 'compose' && state.viewStack.length > 1) {
36888
- dispatch({ type: 'popView' });
36889
- }
38049
+ dispatch({ type: 'navigateHome' });
36890
38050
  // Refresh BEFORE setting the final status so we can peek at the
36891
38051
  // post-apply worktree state and craft a directive next-step hint
36892
38052
  // ("X unstaged + Y untracked remaining — press gs to stage / I
@@ -36934,9 +38094,16 @@ function LogInkApp(deps) {
36934
38094
  });
36935
38095
  return;
36936
38096
  }
36937
- const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
36938
- dispatch({ type: 'setStatus', value: successMessage, kind: 'success' });
36939
- }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.activeView, state.splitPlan, state.viewStack.length]);
38097
+ const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked, result.fallback ? { reason: result.fallback.reason } : undefined);
38098
+ // Fallback path uses 'info' kind apply technically succeeded
38099
+ // but the user should know it landed as a single combined commit
38100
+ // rather than a real LLM-driven multi-group split.
38101
+ dispatch({
38102
+ type: 'setStatus',
38103
+ value: successMessage,
38104
+ kind: result.fallback ? 'info' : 'success',
38105
+ });
38106
+ }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
36940
38107
  // Esc inside the overlay — close without applying. Status line gets
36941
38108
  // a confirmation so the user knows the operation was abandoned.
36942
38109
  const cancelCommitSplit = React.useCallback(() => {
@@ -37443,6 +38610,41 @@ function LogInkApp(deps) {
37443
38610
  'fetch-remotes': async () => fetchRemotes(git),
37444
38611
  'pull-current-branch': async () => pullCurrentBranch(git),
37445
38612
  'push-current-branch': async () => pushCurrentBranch(git),
38613
+ // Per-branch fetch / pull / push that operate on the cursored
38614
+ // row in the branches sidebar. inkInput.ts dispatches these
38615
+ // when F / U / P fire from the sidebar; the *-current-branch
38616
+ // / fetch-remotes variants above still handle the same keys
38617
+ // from any other context.
38618
+ 'fetch-selected-branch': async () => {
38619
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
38620
+ const visible = state.filter
38621
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
38622
+ : all;
38623
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
38624
+ if (!branch)
38625
+ return { ok: false, message: 'No branch selected' };
38626
+ return fetchBranch(git, branch);
38627
+ },
38628
+ 'pull-selected-branch': async () => {
38629
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
38630
+ const visible = state.filter
38631
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
38632
+ : all;
38633
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
38634
+ if (!branch)
38635
+ return { ok: false, message: 'No branch selected' };
38636
+ return pullBranch(git, branch, context.branches?.currentBranch);
38637
+ },
38638
+ 'push-selected-branch': async () => {
38639
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
38640
+ const visible = state.filter
38641
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
38642
+ : all;
38643
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
38644
+ if (!branch)
38645
+ return { ok: false, message: 'No branch selected' };
38646
+ return pushBranch(git, branch);
38647
+ },
37446
38648
  'rename-branch': async () => {
37447
38649
  const newName = payload?.trim();
37448
38650
  if (!newName)
@@ -38360,9 +39562,15 @@ function LogInkApp(deps) {
38360
39562
  else if (event.type === 'runAiCommitDraft') {
38361
39563
  void runAiCommitDraft();
38362
39564
  }
39565
+ else if (event.type === 'cancelAiCommitDraft') {
39566
+ cancelAiCommitDraft();
39567
+ }
38363
39568
  else if (event.type === 'startCreatePullRequest') {
38364
39569
  void startCreatePullRequest();
38365
39570
  }
39571
+ else if (event.type === 'cancelPullRequestBodyDraft') {
39572
+ cancelPullRequestBodyDraft();
39573
+ }
38366
39574
  else if (event.type === 'startChangelogView') {
38367
39575
  void startChangelogView();
38368
39576
  }