git-coco 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +47 -0
- package/dist/index.esm.mjs +1043 -121
- package/dist/index.js +1043 -121
- package/package.json +9 -6
package/dist/index.esm.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
15569
|
-
?
|
|
15570
|
-
:
|
|
15675
|
+
const issuesSummary = lastIssues
|
|
15676
|
+
? formatPlanValidationIssuesError(lastIssues)
|
|
15677
|
+
: 'no captured validator issues';
|
|
15678
|
+
// Strict mode: restore the pre-#1005 behaviour. Callers that pass
|
|
15679
|
+
// `strict: true` (and CLI users via `--strict-split`) want explicit
|
|
15680
|
+
// failure rather than the degraded fallback.
|
|
15681
|
+
if (strict) {
|
|
15682
|
+
throw new Error(lastIssues
|
|
15683
|
+
? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${issuesSummary}`
|
|
15684
|
+
: `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
|
|
15685
|
+
}
|
|
15686
|
+
// Default: hand back a trivially-valid single-group fallback. The
|
|
15687
|
+
// caller's apply / preview surface should treat the `fallback` flag
|
|
15688
|
+
// as a signal to nudge the user (it's strictly better than a hard
|
|
15689
|
+
// failure with the staged set still on disk, but it's still a
|
|
15690
|
+
// degraded outcome compared to a real multi-group split).
|
|
15691
|
+
const reason = `LLM exhausted ${maxAttempts} planning attempts; final validator issues: ${issuesSummary}`;
|
|
15692
|
+
if (logger) {
|
|
15693
|
+
logger.verbose(`Plan attempts exhausted — falling back to a single-group plan. ${reason}`, { color: 'yellow' });
|
|
15694
|
+
}
|
|
15695
|
+
return {
|
|
15696
|
+
plan: buildSplitPlanFallback(staged, { reason: issuesSummary }),
|
|
15697
|
+
attempts: maxAttempts,
|
|
15698
|
+
fallback: {
|
|
15699
|
+
reason,
|
|
15700
|
+
lastIssues: lastIssues ?? {
|
|
15701
|
+
unknownFiles: [],
|
|
15702
|
+
duplicateFiles: [],
|
|
15703
|
+
unknownHunks: [],
|
|
15704
|
+
duplicateHunks: [],
|
|
15705
|
+
mixedFiles: [],
|
|
15706
|
+
partiallyCoveredFiles: [],
|
|
15707
|
+
missingFiles: [],
|
|
15708
|
+
},
|
|
15709
|
+
},
|
|
15710
|
+
};
|
|
15571
15711
|
}
|
|
15572
15712
|
|
|
15573
15713
|
/**
|
|
@@ -15769,7 +15909,7 @@ async function applyPatchToIndex$1(patch, git) {
|
|
|
15769
15909
|
child.stdin.end();
|
|
15770
15910
|
});
|
|
15771
15911
|
}
|
|
15772
|
-
async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, }) {
|
|
15912
|
+
async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, fallback, }) {
|
|
15773
15913
|
validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
|
|
15774
15914
|
assertNoUnstagedOverlap(plan, changes, hunkInventory);
|
|
15775
15915
|
// Defensive: drop any group with empty files[] AND empty hunks[].
|
|
@@ -15872,11 +16012,13 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
|
|
|
15872
16012
|
return {
|
|
15873
16013
|
commitHashes,
|
|
15874
16014
|
message: `Created ${commitHashes.length} of ${applicableGroups.length} planned commit(s). Failed: ${partial}`,
|
|
16015
|
+
fallback,
|
|
15875
16016
|
};
|
|
15876
16017
|
}
|
|
15877
16018
|
return {
|
|
15878
16019
|
commitHashes,
|
|
15879
16020
|
message: `Created ${commitHashes.length} split commit(s).`,
|
|
16021
|
+
fallback,
|
|
15880
16022
|
};
|
|
15881
16023
|
}
|
|
15882
16024
|
/**
|
|
@@ -15974,7 +16116,7 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15974
16116
|
}
|
|
15975
16117
|
const resolvedPlanLlm = planLlm ?? llm;
|
|
15976
16118
|
const resolvedPlanModel = planService?.model ?? config.service.model;
|
|
15977
|
-
const { plan } = await generateValidatedCommitSplitPlan({
|
|
16119
|
+
const { plan, fallback } = await generateValidatedCommitSplitPlan({
|
|
15978
16120
|
llm: resolvedPlanLlm,
|
|
15979
16121
|
prompt: COMMIT_SPLIT_PROMPT,
|
|
15980
16122
|
variables: {
|
|
@@ -15997,8 +16139,12 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15997
16139
|
conventional: useConventional,
|
|
15998
16140
|
},
|
|
15999
16141
|
maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
|
|
16142
|
+
// Honour `--strict-split` (CLI) or `strictSplit` (config). When set,
|
|
16143
|
+
// the planner reverts to the pre-#1005 behaviour of throwing on
|
|
16144
|
+
// exhaustion instead of returning the single-group fallback.
|
|
16145
|
+
strict: Boolean(argv.strictSplit ?? config.strictSplit),
|
|
16000
16146
|
});
|
|
16001
|
-
return { plan, context: { changes, hunkInventory } };
|
|
16147
|
+
return { plan, context: { changes, hunkInventory }, fallback };
|
|
16002
16148
|
}
|
|
16003
16149
|
async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
|
|
16004
16150
|
const result = await prepareCommitSplitPlan({
|
|
@@ -16014,7 +16160,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
|
|
|
16014
16160
|
if ('empty' in result) {
|
|
16015
16161
|
return 'No staged changes found.';
|
|
16016
16162
|
}
|
|
16017
|
-
const { plan, context } = result;
|
|
16163
|
+
const { plan, context, fallback } = result;
|
|
16018
16164
|
if (argv.apply) {
|
|
16019
16165
|
const applied = await applyCommitSplitPlan({
|
|
16020
16166
|
plan,
|
|
@@ -16023,9 +16169,24 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
|
|
|
16023
16169
|
git,
|
|
16024
16170
|
logger,
|
|
16025
16171
|
noVerify: argv.noVerify || config.noVerify || false,
|
|
16172
|
+
fallback,
|
|
16026
16173
|
});
|
|
16174
|
+
if (applied.fallback) {
|
|
16175
|
+
return [
|
|
16176
|
+
`Note: applied the single-commit fallback (${applied.fallback.reason}).`,
|
|
16177
|
+
applied.message,
|
|
16178
|
+
].join('\n');
|
|
16179
|
+
}
|
|
16027
16180
|
return applied.message;
|
|
16028
16181
|
}
|
|
16182
|
+
if (fallback) {
|
|
16183
|
+
return [
|
|
16184
|
+
`Note: showing the single-commit fallback plan (${fallback.reason}).`,
|
|
16185
|
+
'Re-run with a stronger model or use --strict-split to surface the planner error.',
|
|
16186
|
+
'',
|
|
16187
|
+
formatCommitSplitPlan(plan),
|
|
16188
|
+
].join('\n');
|
|
16189
|
+
}
|
|
16029
16190
|
return formatCommitSplitPlan(plan);
|
|
16030
16191
|
}
|
|
16031
16192
|
|
|
@@ -20064,9 +20225,13 @@ function applyCommitComposeAction(state, action) {
|
|
|
20064
20225
|
editing: action.value,
|
|
20065
20226
|
};
|
|
20066
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.
|
|
20067
20231
|
return {
|
|
20068
20232
|
...state,
|
|
20069
20233
|
loading: action.value,
|
|
20234
|
+
streamingPreview: action.value ? state.streamingPreview : undefined,
|
|
20070
20235
|
};
|
|
20071
20236
|
case 'setDraft':
|
|
20072
20237
|
// No `message` here — the loader → filled fields are the confirmation
|
|
@@ -20082,6 +20247,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20082
20247
|
loading: false,
|
|
20083
20248
|
message: undefined,
|
|
20084
20249
|
details: undefined,
|
|
20250
|
+
streamingPreview: undefined,
|
|
20085
20251
|
};
|
|
20086
20252
|
case 'setResult':
|
|
20087
20253
|
return {
|
|
@@ -20089,6 +20255,17 @@ function applyCommitComposeAction(state, action) {
|
|
|
20089
20255
|
loading: false,
|
|
20090
20256
|
message: action.message,
|
|
20091
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,
|
|
20092
20269
|
};
|
|
20093
20270
|
case 'reset':
|
|
20094
20271
|
// Drop message/details too — the post-commit "Created commit ..."
|
|
@@ -20162,6 +20339,210 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
|
|
|
20162
20339
|
}
|
|
20163
20340
|
}
|
|
20164
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
|
+
|
|
20165
20546
|
const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
|
|
20166
20547
|
|
|
20167
20548
|
REQUIRED JSON FORMAT:
|
|
@@ -20186,7 +20567,45 @@ IMPORTANT RULES:
|
|
|
20186
20567
|
* are surfaced as `validationErrors`/`warnings` rather than driving an
|
|
20187
20568
|
* interactive retry flow — the TUI can re-invoke or let the user edit.
|
|
20188
20569
|
*/
|
|
20189
|
-
|
|
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, }) {
|
|
20190
20609
|
const config = loadConfig(argv);
|
|
20191
20610
|
const key = getApiKeyForModel(config);
|
|
20192
20611
|
const { provider } = getModelAndProviderFromConfig(config);
|
|
@@ -20329,42 +20748,117 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20329
20748
|
tokenizer,
|
|
20330
20749
|
maxTokens: config.service.tokenLimit || 2048,
|
|
20331
20750
|
});
|
|
20332
|
-
|
|
20333
|
-
|
|
20334
|
-
|
|
20335
|
-
|
|
20336
|
-
|
|
20337
|
-
|
|
20338
|
-
|
|
20339
|
-
|
|
20340
|
-
|
|
20341
|
-
|
|
20342
|
-
|
|
20343
|
-
|
|
20344
|
-
|
|
20345
|
-
|
|
20346
|
-
|
|
20347
|
-
|
|
20348
|
-
|
|
20349
|
-
|
|
20350
|
-
|
|
20351
|
-
|
|
20352
|
-
|
|
20353
|
-
|
|
20354
|
-
|
|
20355
|
-
|
|
20356
|
-
|
|
20357
|
-
|
|
20358
|
-
|
|
20359
|
-
|
|
20360
|
-
|
|
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
|
+
};
|
|
20361
20812
|
}
|
|
20362
|
-
|
|
20363
|
-
|
|
20364
|
-
|
|
20365
|
-
|
|
20366
|
-
|
|
20367
|
-
|
|
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
|
+
}
|
|
20368
20862
|
const ticketId = extractTicketIdFromBranchName(branchName);
|
|
20369
20863
|
const fullMessage = formatCommitMessage(commitMsg, {
|
|
20370
20864
|
append: argv.append,
|
|
@@ -20462,8 +20956,26 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
20462
20956
|
const argv = createCommitWorkflowArgv('commit');
|
|
20463
20957
|
const logger = new Logger({ silent: true });
|
|
20464
20958
|
try {
|
|
20465
|
-
const result = await generateCommitDraft({
|
|
20959
|
+
const result = await generateCommitDraft({
|
|
20960
|
+
git,
|
|
20961
|
+
argv,
|
|
20962
|
+
logger,
|
|
20963
|
+
onStreamChunk: input.onStreamChunk,
|
|
20964
|
+
signal: input.signal,
|
|
20965
|
+
});
|
|
20466
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
|
+
}
|
|
20467
20979
|
if (result.ok && draft) {
|
|
20468
20980
|
return {
|
|
20469
20981
|
ok: true,
|
|
@@ -20552,6 +21064,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
20552
21064
|
ok: true,
|
|
20553
21065
|
plan: result.plan,
|
|
20554
21066
|
planContext: result.context,
|
|
21067
|
+
fallback: result.fallback,
|
|
20555
21068
|
};
|
|
20556
21069
|
}
|
|
20557
21070
|
catch (error) {
|
|
@@ -20596,6 +21109,7 @@ async function runCommitSplitApplyWorkflow(input) {
|
|
|
20596
21109
|
git,
|
|
20597
21110
|
logger,
|
|
20598
21111
|
noVerify: input.noVerify || false,
|
|
21112
|
+
fallback: input.fallback,
|
|
20599
21113
|
});
|
|
20600
21114
|
return {
|
|
20601
21115
|
ok: true,
|
|
@@ -20606,6 +21120,7 @@ async function runCommitSplitApplyWorkflow(input) {
|
|
|
20606
21120
|
// I/O AND inaccurate when partial-apply landed fewer commits
|
|
20607
21121
|
// than the plan had groups.
|
|
20608
21122
|
commitHashes: applied.commitHashes,
|
|
21123
|
+
fallback: applied.fallback,
|
|
20609
21124
|
};
|
|
20610
21125
|
}
|
|
20611
21126
|
catch (error) {
|
|
@@ -22750,10 +23265,17 @@ function withPoppedView(state) {
|
|
|
22750
23265
|
* in a clean slate — the mental equivalent of a fresh `coco ui`
|
|
22751
23266
|
* launched against the submodule's working dir.
|
|
22752
23267
|
*
|
|
22753
|
-
*
|
|
22754
|
-
*
|
|
22755
|
-
*
|
|
22756
|
-
*
|
|
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.
|
|
22757
23279
|
*
|
|
22758
23280
|
* Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
|
|
22759
23281
|
* outside the reducer in `app.ts`'s parallel ref structure — this
|
|
@@ -22770,6 +23292,10 @@ function withPushedRepoFrame(state, payload) {
|
|
|
22770
23292
|
selectedFileIndex: state.selectedFileIndex,
|
|
22771
23293
|
selectedSubmoduleIndex: state.selectedSubmoduleIndex,
|
|
22772
23294
|
filter: state.filter,
|
|
23295
|
+
sidebarTab: state.sidebarTab,
|
|
23296
|
+
userSidebarTab: state.userSidebarTab,
|
|
23297
|
+
branchSort: state.branchSort,
|
|
23298
|
+
tagSort: state.tagSort,
|
|
22773
23299
|
},
|
|
22774
23300
|
};
|
|
22775
23301
|
return {
|
|
@@ -22822,6 +23348,15 @@ function withPoppedRepoFrame(state) {
|
|
|
22822
23348
|
filter: ret.filter,
|
|
22823
23349
|
filterMode: false,
|
|
22824
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,
|
|
22825
23360
|
pendingKey: undefined,
|
|
22826
23361
|
pendingConfirmationId: undefined,
|
|
22827
23362
|
pendingConfirmationPayload: undefined,
|
|
@@ -23597,6 +24132,17 @@ function applyLogInkAction(state, action) {
|
|
|
23597
24132
|
statusLoading: !action.value ? undefined : (action.loading ? true : undefined),
|
|
23598
24133
|
pendingKey: undefined,
|
|
23599
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
|
+
};
|
|
23600
24146
|
case 'setWorkflowAction':
|
|
23601
24147
|
return {
|
|
23602
24148
|
...state,
|
|
@@ -23840,6 +24386,7 @@ function applyLogInkAction(state, action) {
|
|
|
23840
24386
|
plan: action.plan,
|
|
23841
24387
|
planContext: action.planContext,
|
|
23842
24388
|
scrollOffset: 0,
|
|
24389
|
+
fallback: action.fallback,
|
|
23843
24390
|
},
|
|
23844
24391
|
pendingKey: undefined,
|
|
23845
24392
|
};
|
|
@@ -24616,6 +25163,36 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24616
25163
|
}
|
|
24617
25164
|
return [];
|
|
24618
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
|
+
}
|
|
24619
25196
|
if (state.commitCompose.editing) {
|
|
24620
25197
|
if (key.escape) {
|
|
24621
25198
|
return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
|
|
@@ -26632,17 +27209,24 @@ function formatRemainingWorktreeHint(unstaged, untracked) {
|
|
|
26632
27209
|
*
|
|
26633
27210
|
* When the worktree is clean post-apply:
|
|
26634
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.
|
|
26635
27218
|
*/
|
|
26636
|
-
function formatSplitApplySuccess(commitCount, unstaged, untracked) {
|
|
27219
|
+
function formatSplitApplySuccess(commitCount, unstaged, untracked, fallback) {
|
|
26637
27220
|
const created = commitCount === 1
|
|
26638
27221
|
? 'Created 1 commit'
|
|
26639
27222
|
: `Created ${commitCount} commits`;
|
|
26640
27223
|
const navCue = `${created} — press gh to view them in history.`;
|
|
26641
27224
|
const remainingHint = formatRemainingWorktreeHint(unstaged, untracked);
|
|
26642
|
-
|
|
26643
|
-
|
|
27225
|
+
const tail = remainingHint ? ` ${remainingHint}` : ' Worktree is clean.';
|
|
27226
|
+
if (fallback) {
|
|
27227
|
+
return `Split planner fallback applied (combined commit) — ${fallback.reason}. ${navCue}${tail}`;
|
|
26644
27228
|
}
|
|
26645
|
-
return `${navCue}
|
|
27229
|
+
return `${navCue}${tail}`;
|
|
26646
27230
|
}
|
|
26647
27231
|
|
|
26648
27232
|
/**
|
|
@@ -30540,6 +31124,97 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
|
|
|
30540
31124
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
|
|
30541
31125
|
}
|
|
30542
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
|
+
|
|
30543
31218
|
/**
|
|
30544
31219
|
* Compose surface — the in-TUI commit-message composer. Combines a
|
|
30545
31220
|
* summary line, a body field, and a state-line footer; an inline
|
|
@@ -30549,6 +31224,33 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
|
|
|
30549
31224
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
30550
31225
|
* of #890. No behavior change.
|
|
30551
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
|
+
}
|
|
30552
31254
|
function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
|
|
30553
31255
|
const { Box, Text } = components;
|
|
30554
31256
|
const compose = state.commitCompose;
|
|
@@ -30572,9 +31274,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
30572
31274
|
: ['<empty>'];
|
|
30573
31275
|
const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
|
|
30574
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.
|
|
30575
31282
|
const stateLine = compose.editing
|
|
30576
31283
|
? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
|
|
30577
|
-
:
|
|
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.';
|
|
30578
31287
|
const hasStagedFiles = (worktree?.files || [])
|
|
30579
31288
|
.some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
|
|
30580
31289
|
// Staged file list is rendered in the right Worktree panel
|
|
@@ -30621,6 +31330,13 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
30621
31330
|
}, theme.ascii
|
|
30622
31331
|
? `[${pickSpinnerFrame(spinnerFrame).replace(/[^a-zA-Z0-9 ]/g, '.')}] Generating AI commit draft (this can take a moment)`
|
|
30623
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),
|
|
30624
31340
|
]
|
|
30625
31341
|
: []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncateCells(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
|
|
30626
31342
|
key: `compose-detail-${index}`,
|
|
@@ -35494,9 +36210,18 @@ function LogInkApp(deps) {
|
|
|
35494
36210
|
// Wrappers that delegate to the active frame's runtime entry so the
|
|
35495
36211
|
// existing call sites stay byte-identical. Support both function-
|
|
35496
36212
|
// updater and value-updater forms (the codebase uses both).
|
|
35497
|
-
|
|
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) => {
|
|
35498
36223
|
setRuntimes((prev) => {
|
|
35499
|
-
const depth = prev.length - 1;
|
|
36224
|
+
const depth = targetDepth ?? prev.length - 1;
|
|
35500
36225
|
if (depth < 0)
|
|
35501
36226
|
return prev;
|
|
35502
36227
|
return updateRepoFrameRuntime(prev, depth, (frame) => ({
|
|
@@ -35507,9 +36232,9 @@ function LogInkApp(deps) {
|
|
|
35507
36232
|
}));
|
|
35508
36233
|
});
|
|
35509
36234
|
}, []);
|
|
35510
|
-
const setContextStatus = React.useCallback((arg) => {
|
|
36235
|
+
const setContextStatus = React.useCallback((arg, targetDepth) => {
|
|
35511
36236
|
setRuntimes((prev) => {
|
|
35512
|
-
const depth = prev.length - 1;
|
|
36237
|
+
const depth = targetDepth ?? prev.length - 1;
|
|
35513
36238
|
if (depth < 0)
|
|
35514
36239
|
return prev;
|
|
35515
36240
|
return updateRepoFrameRuntime(prev, depth, (frame) => ({
|
|
@@ -35835,28 +36560,39 @@ function LogInkApp(deps) {
|
|
|
35835
36560
|
// (stale-while-revalidate) and quietly swap it in once the new fetch
|
|
35836
36561
|
// resolves — avoids the every-second flicker the watcher would
|
|
35837
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;
|
|
35838
36571
|
if (!options.silent) {
|
|
35839
36572
|
dispatch({ type: 'setStatus', value: 'refreshing repository context' });
|
|
35840
|
-
setContextStatus(createLogInkContextStatus('loading'));
|
|
36573
|
+
setContextStatus(createLogInkContextStatus('loading'), issuedAtDepth);
|
|
35841
36574
|
}
|
|
35842
36575
|
const next = await loadLogInkContext(git);
|
|
35843
|
-
setContext(next);
|
|
35844
|
-
setContextStatus(createLogInkContextStatus('ready'));
|
|
36576
|
+
setContext(next, issuedAtDepth);
|
|
36577
|
+
setContextStatus(createLogInkContextStatus('ready'), issuedAtDepth);
|
|
35845
36578
|
if (!options.silent) {
|
|
35846
36579
|
dispatch({ type: 'setStatus', value: 'repository context refreshed' });
|
|
35847
36580
|
}
|
|
35848
|
-
}, [dispatch, git]);
|
|
36581
|
+
}, [dispatch, git, runtimes.length, setContext, setContextStatus]);
|
|
35849
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;
|
|
35850
36586
|
if (!options.silent) {
|
|
35851
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
|
|
36587
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'), issuedAtDepth);
|
|
35852
36588
|
}
|
|
35853
36589
|
const worktree = await safe(getWorktreeOverview(git));
|
|
35854
36590
|
setContext((current) => ({
|
|
35855
36591
|
...current,
|
|
35856
36592
|
worktree,
|
|
35857
|
-
}));
|
|
35858
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
|
|
35859
|
-
}, [git]);
|
|
36593
|
+
}), issuedAtDepth);
|
|
36594
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
|
|
36595
|
+
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
35860
36596
|
// Live refresh: watch .git metadata + the working tree root and reload
|
|
35861
36597
|
// context when something changes outside the TUI (editor save, external
|
|
35862
36598
|
// git commands, branch switch in another terminal). Best-effort — the
|
|
@@ -36081,6 +36817,11 @@ function LogInkApp(deps) {
|
|
|
36081
36817
|
const contextStatusRef = React.useRef(contextStatus);
|
|
36082
36818
|
contextStatusRef.current = contextStatus;
|
|
36083
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;
|
|
36084
36825
|
let active = true;
|
|
36085
36826
|
loadLogInkContextEntries(git).forEach(({ key, load }) => {
|
|
36086
36827
|
if (contextStatusRef.current[key] === 'ready')
|
|
@@ -36092,14 +36833,14 @@ function LogInkApp(deps) {
|
|
|
36092
36833
|
setContext((current) => ({
|
|
36093
36834
|
...current,
|
|
36094
36835
|
[key]: value,
|
|
36095
|
-
}));
|
|
36096
|
-
setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'));
|
|
36836
|
+
}), issuedAtDepth);
|
|
36837
|
+
setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'), issuedAtDepth);
|
|
36097
36838
|
});
|
|
36098
36839
|
});
|
|
36099
36840
|
return () => {
|
|
36100
36841
|
active = false;
|
|
36101
36842
|
};
|
|
36102
|
-
}, [git]);
|
|
36843
|
+
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
36103
36844
|
// Lazy-load the full pullRequest overview (#808). Only fires when
|
|
36104
36845
|
// the user actually navigates to the PR view, and only when we
|
|
36105
36846
|
// don't already have data (so a workflow-triggered refresh that
|
|
@@ -36113,21 +36854,22 @@ function LogInkApp(deps) {
|
|
|
36113
36854
|
return;
|
|
36114
36855
|
if (context.pullRequest)
|
|
36115
36856
|
return;
|
|
36857
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36116
36858
|
let active = true;
|
|
36117
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
|
|
36859
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'), issuedAtDepth);
|
|
36118
36860
|
void safe(getPullRequestOverview(git)).then((value) => {
|
|
36119
36861
|
if (!active)
|
|
36120
36862
|
return;
|
|
36121
36863
|
setContext((current) => ({
|
|
36122
36864
|
...current,
|
|
36123
36865
|
pullRequest: value,
|
|
36124
|
-
}));
|
|
36125
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
|
|
36866
|
+
}), issuedAtDepth);
|
|
36867
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'), issuedAtDepth);
|
|
36126
36868
|
});
|
|
36127
36869
|
return () => {
|
|
36128
36870
|
active = false;
|
|
36129
36871
|
};
|
|
36130
|
-
}, [git, state.activeView, context.pullRequest]);
|
|
36872
|
+
}, [git, runtimes.length, state.activeView, context.pullRequest, setContext, setContextStatus]);
|
|
36131
36873
|
// Lazy-load the issue triage list (#882 phase 3, filter-aware
|
|
36132
36874
|
// since phase 6). Fires on entry to the view AND on filter
|
|
36133
36875
|
// preset changes (`f` cycles the preset; the dep on
|
|
@@ -36139,8 +36881,9 @@ function LogInkApp(deps) {
|
|
|
36139
36881
|
return;
|
|
36140
36882
|
if (context.issueList)
|
|
36141
36883
|
return;
|
|
36884
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36142
36885
|
let active = true;
|
|
36143
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
|
|
36886
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'), issuedAtDepth);
|
|
36144
36887
|
const filter = issueFilterForPreset(state.selectedIssueFilter);
|
|
36145
36888
|
void safe(getIssueList(git, filter)).then((value) => {
|
|
36146
36889
|
if (!active)
|
|
@@ -36148,13 +36891,21 @@ function LogInkApp(deps) {
|
|
|
36148
36891
|
setContext((current) => ({
|
|
36149
36892
|
...current,
|
|
36150
36893
|
issueList: value,
|
|
36151
|
-
}));
|
|
36152
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
|
|
36894
|
+
}), issuedAtDepth);
|
|
36895
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'), issuedAtDepth);
|
|
36153
36896
|
});
|
|
36154
36897
|
return () => {
|
|
36155
36898
|
active = false;
|
|
36156
36899
|
};
|
|
36157
|
-
}, [
|
|
36900
|
+
}, [
|
|
36901
|
+
git,
|
|
36902
|
+
runtimes.length,
|
|
36903
|
+
state.activeView,
|
|
36904
|
+
context.issueList,
|
|
36905
|
+
state.selectedIssueFilter,
|
|
36906
|
+
setContext,
|
|
36907
|
+
setContextStatus,
|
|
36908
|
+
]);
|
|
36158
36909
|
// Filter cycling: when the preset changes, drop the cached list
|
|
36159
36910
|
// so the effect above re-fires with the new filter. Done as a
|
|
36160
36911
|
// separate effect (rather than folded into the cycle reducer)
|
|
@@ -36178,8 +36929,9 @@ function LogInkApp(deps) {
|
|
|
36178
36929
|
return;
|
|
36179
36930
|
if (context.pullRequestList)
|
|
36180
36931
|
return;
|
|
36932
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36181
36933
|
let active = true;
|
|
36182
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
|
|
36934
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'), issuedAtDepth);
|
|
36183
36935
|
const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
|
|
36184
36936
|
void safe(getPullRequestList(git, filter)).then((value) => {
|
|
36185
36937
|
if (!active)
|
|
@@ -36187,13 +36939,21 @@ function LogInkApp(deps) {
|
|
|
36187
36939
|
setContext((current) => ({
|
|
36188
36940
|
...current,
|
|
36189
36941
|
pullRequestList: value,
|
|
36190
|
-
}));
|
|
36191
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
|
|
36942
|
+
}), issuedAtDepth);
|
|
36943
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'), issuedAtDepth);
|
|
36192
36944
|
});
|
|
36193
36945
|
return () => {
|
|
36194
36946
|
active = false;
|
|
36195
36947
|
};
|
|
36196
|
-
}, [
|
|
36948
|
+
}, [
|
|
36949
|
+
git,
|
|
36950
|
+
runtimes.length,
|
|
36951
|
+
state.activeView,
|
|
36952
|
+
context.pullRequestList,
|
|
36953
|
+
state.selectedPullRequestFilter,
|
|
36954
|
+
setContext,
|
|
36955
|
+
setContextStatus,
|
|
36956
|
+
]);
|
|
36197
36957
|
React.useEffect(() => {
|
|
36198
36958
|
if (state.activeView !== 'pull-request-triage')
|
|
36199
36959
|
return;
|
|
@@ -36223,6 +36983,7 @@ function LogInkApp(deps) {
|
|
|
36223
36983
|
return;
|
|
36224
36984
|
if (context.issueDetailByNumber?.has(cursored.number))
|
|
36225
36985
|
return;
|
|
36986
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36226
36987
|
let active = true;
|
|
36227
36988
|
const timer = setTimeout(async () => {
|
|
36228
36989
|
const result = await getIssueDetail(cursored.number);
|
|
@@ -36231,17 +36992,19 @@ function LogInkApp(deps) {
|
|
|
36231
36992
|
setContext((current) => ({
|
|
36232
36993
|
...current,
|
|
36233
36994
|
issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
|
|
36234
|
-
}));
|
|
36995
|
+
}), issuedAtDepth);
|
|
36235
36996
|
}, DETAIL_HYDRATION_DELAY_MS);
|
|
36236
36997
|
return () => {
|
|
36237
36998
|
active = false;
|
|
36238
36999
|
clearTimeout(timer);
|
|
36239
37000
|
};
|
|
36240
37001
|
}, [
|
|
37002
|
+
runtimes.length,
|
|
36241
37003
|
state.activeView,
|
|
36242
37004
|
state.selectedIssueIndex,
|
|
36243
37005
|
filteredIssueList,
|
|
36244
37006
|
context.issueDetailByNumber,
|
|
37007
|
+
setContext,
|
|
36245
37008
|
]);
|
|
36246
37009
|
React.useEffect(() => {
|
|
36247
37010
|
if (state.activeView !== 'pull-request-triage')
|
|
@@ -36251,6 +37014,7 @@ function LogInkApp(deps) {
|
|
|
36251
37014
|
return;
|
|
36252
37015
|
if (context.pullRequestDetailByNumber?.has(cursored.number))
|
|
36253
37016
|
return;
|
|
37017
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36254
37018
|
let active = true;
|
|
36255
37019
|
const timer = setTimeout(async () => {
|
|
36256
37020
|
const result = await getPullRequestDetail(cursored.number);
|
|
@@ -36259,17 +37023,19 @@ function LogInkApp(deps) {
|
|
|
36259
37023
|
setContext((current) => ({
|
|
36260
37024
|
...current,
|
|
36261
37025
|
pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
|
|
36262
|
-
}));
|
|
37026
|
+
}), issuedAtDepth);
|
|
36263
37027
|
}, DETAIL_HYDRATION_DELAY_MS);
|
|
36264
37028
|
return () => {
|
|
36265
37029
|
active = false;
|
|
36266
37030
|
clearTimeout(timer);
|
|
36267
37031
|
};
|
|
36268
37032
|
}, [
|
|
37033
|
+
runtimes.length,
|
|
36269
37034
|
state.activeView,
|
|
36270
37035
|
state.selectedPullRequestTriageIndex,
|
|
36271
37036
|
filteredPullRequestTriageList,
|
|
36272
37037
|
context.pullRequestDetailByNumber,
|
|
37038
|
+
setContext,
|
|
36273
37039
|
]);
|
|
36274
37040
|
React.useEffect(() => {
|
|
36275
37041
|
let active = true;
|
|
@@ -36530,21 +37296,96 @@ function LogInkApp(deps) {
|
|
|
36530
37296
|
state.commitCompose.body,
|
|
36531
37297
|
state.commitCompose.summary,
|
|
36532
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);
|
|
36533
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;
|
|
36534
37314
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
|
|
36535
37315
|
dispatch({ type: 'setStatus', value: 'generating AI commit draft', loading: true });
|
|
36536
|
-
|
|
36537
|
-
|
|
36538
|
-
|
|
36539
|
-
|
|
36540
|
-
|
|
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 });
|
|
36541
37359
|
}
|
|
36542
|
-
|
|
36543
|
-
|
|
36544
|
-
|
|
36545
|
-
|
|
36546
|
-
|
|
36547
|
-
|
|
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
|
+
}, []);
|
|
36548
37389
|
// `C` keystroke handler — start the create-pull-request flow. Resolves
|
|
36549
37390
|
// the head + base branches from the live context, runs
|
|
36550
37391
|
// `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
|
|
@@ -36558,6 +37399,19 @@ function LogInkApp(deps) {
|
|
|
36558
37399
|
// missing) we surface the failure on the status line and skip the
|
|
36559
37400
|
// prompt entirely — better than opening a prompt the user can't
|
|
36560
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);
|
|
36561
37415
|
const startCreatePullRequest = React.useCallback(async () => {
|
|
36562
37416
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
36563
37417
|
if (!head) {
|
|
@@ -36586,32 +37440,61 @@ function LogInkApp(deps) {
|
|
|
36586
37440
|
});
|
|
36587
37441
|
return;
|
|
36588
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 });
|
|
36589
37451
|
dispatch({
|
|
36590
37452
|
type: 'setStatus',
|
|
36591
|
-
value: `generating PR body from changelog (vs ${defaultBranch})
|
|
37453
|
+
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
|
|
36592
37454
|
loading: true,
|
|
36593
37455
|
});
|
|
36594
|
-
|
|
36595
|
-
|
|
36596
|
-
|
|
36597
|
-
|
|
36598
|
-
|
|
36599
|
-
|
|
36600
|
-
|
|
36601
|
-
|
|
36602
|
-
|
|
36603
|
-
|
|
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
|
+
});
|
|
36604
37487
|
}
|
|
36605
|
-
|
|
36606
|
-
|
|
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
|
+
}
|
|
36607
37497
|
}
|
|
36608
|
-
dispatch({
|
|
36609
|
-
type: 'openInputPrompt',
|
|
36610
|
-
kind: 'create-pr',
|
|
36611
|
-
label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
|
|
36612
|
-
initial,
|
|
36613
|
-
multiline: true,
|
|
36614
|
-
});
|
|
36615
37498
|
}, [
|
|
36616
37499
|
context.branches?.currentBranch,
|
|
36617
37500
|
context.provider?.currentBranch,
|
|
@@ -36620,6 +37503,24 @@ function LogInkApp(deps) {
|
|
|
36620
37503
|
context.pullRequest?.currentPullRequest,
|
|
36621
37504
|
dispatch,
|
|
36622
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
|
+
}, []);
|
|
36623
37524
|
// Copy an arbitrary string to the system clipboard. Distinct from
|
|
36624
37525
|
// `yankFromActiveView` which derives the value from the current view
|
|
36625
37526
|
// — this one takes the value as an explicit event payload, used by
|
|
@@ -37045,11 +37946,18 @@ function LogInkApp(deps) {
|
|
|
37045
37946
|
type: 'setSplitPlanReady',
|
|
37046
37947
|
plan: result.plan,
|
|
37047
37948
|
planContext: result.planContext,
|
|
37949
|
+
fallback: result.fallback,
|
|
37048
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.
|
|
37049
37957
|
dispatch({
|
|
37050
37958
|
type: 'setStatus',
|
|
37051
|
-
value:
|
|
37052
|
-
kind: 'success',
|
|
37959
|
+
value: readyMessage,
|
|
37960
|
+
kind: result.fallback ? 'info' : 'success',
|
|
37053
37961
|
});
|
|
37054
37962
|
}, [context.operation, context.worktree?.stagedCount, dispatch, git]);
|
|
37055
37963
|
// `y`/Enter inside the overlay — apply the previewed plan. Uses the
|
|
@@ -37091,6 +37999,7 @@ function LogInkApp(deps) {
|
|
|
37091
37999
|
plan: splitPlan.plan,
|
|
37092
38000
|
planContext: splitPlan.planContext,
|
|
37093
38001
|
git,
|
|
38002
|
+
fallback: splitPlan.fallback,
|
|
37094
38003
|
});
|
|
37095
38004
|
dump.push(`workflow returned: ok=${result.ok} message="${result.message}" commitHashes=[${(result.commitHashes || []).join(', ')}]`);
|
|
37096
38005
|
try {
|
|
@@ -37185,8 +38094,15 @@ function LogInkApp(deps) {
|
|
|
37185
38094
|
});
|
|
37186
38095
|
return;
|
|
37187
38096
|
}
|
|
37188
|
-
const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
|
|
37189
|
-
|
|
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
|
+
});
|
|
37190
38106
|
}, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
|
|
37191
38107
|
// Esc inside the overlay — close without applying. Status line gets
|
|
37192
38108
|
// a confirmation so the user knows the operation was abandoned.
|
|
@@ -38646,9 +39562,15 @@ function LogInkApp(deps) {
|
|
|
38646
39562
|
else if (event.type === 'runAiCommitDraft') {
|
|
38647
39563
|
void runAiCommitDraft();
|
|
38648
39564
|
}
|
|
39565
|
+
else if (event.type === 'cancelAiCommitDraft') {
|
|
39566
|
+
cancelAiCommitDraft();
|
|
39567
|
+
}
|
|
38649
39568
|
else if (event.type === 'startCreatePullRequest') {
|
|
38650
39569
|
void startCreatePullRequest();
|
|
38651
39570
|
}
|
|
39571
|
+
else if (event.type === 'cancelPullRequestBodyDraft') {
|
|
39572
|
+
cancelPullRequestBodyDraft();
|
|
39573
|
+
}
|
|
38652
39574
|
else if (event.type === 'startChangelogView') {
|
|
38653
39575
|
void startChangelogView();
|
|
38654
39576
|
}
|