git-coco 0.53.0 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +47 -0
- package/dist/index.esm.mjs +1293 -134
- package/dist/index.js +1293 -134
- package/package.json +9 -6
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.54.1";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -325,6 +325,25 @@ class LangChainNetworkError extends LangChainError {
|
|
|
325
325
|
this.provider = provider;
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* User-initiated cancellation (#881 phase 3). Thrown by streaming
|
|
330
|
+
* helpers when an `AbortSignal` they were given fires. Distinct from
|
|
331
|
+
* `LangChainNetworkError` / `LangChainTimeoutError` so callers can
|
|
332
|
+
* pattern-match: a cancelled LLM call is the user's intent, not a
|
|
333
|
+
* failure to surface in the status line as an error.
|
|
334
|
+
*
|
|
335
|
+
* Carries the accumulated text up to the cancel point (when
|
|
336
|
+
* available) so the caller can decide whether to salvage a partial
|
|
337
|
+
* result or discard it. Today the workstation discards — the
|
|
338
|
+
* preview pane was the only consumer of the accumulated text and it
|
|
339
|
+
* gets cleared on cancel anyway.
|
|
340
|
+
*/
|
|
341
|
+
class LangChainCancelledError extends LangChainError {
|
|
342
|
+
constructor(message, accumulated, context) {
|
|
343
|
+
super(message, { ...context, accumulated });
|
|
344
|
+
this.accumulated = accumulated;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
328
347
|
|
|
329
348
|
/**
|
|
330
349
|
* Validates that a required parameter is not null or undefined
|
|
@@ -1319,6 +1338,18 @@ const schema$1 = {
|
|
|
1319
1338
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1320
1339
|
"default": "balanced"
|
|
1321
1340
|
},
|
|
1341
|
+
"streaming": {
|
|
1342
|
+
"type": "object",
|
|
1343
|
+
"properties": {
|
|
1344
|
+
"enabled": {
|
|
1345
|
+
"type": "boolean",
|
|
1346
|
+
"description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
|
|
1347
|
+
"default": false
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
"additionalProperties": false,
|
|
1351
|
+
"description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
|
|
1352
|
+
},
|
|
1322
1353
|
"fastPath": {
|
|
1323
1354
|
"type": "object",
|
|
1324
1355
|
"properties": {
|
|
@@ -1773,6 +1804,18 @@ const schema$1 = {
|
|
|
1773
1804
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1774
1805
|
"default": "balanced"
|
|
1775
1806
|
},
|
|
1807
|
+
"streaming": {
|
|
1808
|
+
"type": "object",
|
|
1809
|
+
"properties": {
|
|
1810
|
+
"enabled": {
|
|
1811
|
+
"type": "boolean",
|
|
1812
|
+
"description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
|
|
1813
|
+
"default": false
|
|
1814
|
+
}
|
|
1815
|
+
},
|
|
1816
|
+
"additionalProperties": false,
|
|
1817
|
+
"description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
|
|
1818
|
+
},
|
|
1776
1819
|
"fastPath": {
|
|
1777
1820
|
"type": "object",
|
|
1778
1821
|
"properties": {
|
|
@@ -1967,6 +2010,18 @@ const schema$1 = {
|
|
|
1967
2010
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1968
2011
|
"default": "balanced"
|
|
1969
2012
|
},
|
|
2013
|
+
"streaming": {
|
|
2014
|
+
"type": "object",
|
|
2015
|
+
"properties": {
|
|
2016
|
+
"enabled": {
|
|
2017
|
+
"type": "boolean",
|
|
2018
|
+
"description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
|
|
2019
|
+
"default": false
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
"additionalProperties": false,
|
|
2023
|
+
"description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
|
|
2024
|
+
},
|
|
1970
2025
|
"fastPath": {
|
|
1971
2026
|
"type": "object",
|
|
1972
2027
|
"properties": {
|
|
@@ -7960,7 +8015,7 @@ async function enforcePromptBudget({ prompt, variables, tokenizer, maxTokens, su
|
|
|
7960
8015
|
/**
|
|
7961
8016
|
* Extracts provider and endpoint info from LLM instance if available
|
|
7962
8017
|
*/
|
|
7963
|
-
function extractLlmInfo(llm) {
|
|
8018
|
+
function extractLlmInfo$1(llm) {
|
|
7964
8019
|
const info = {};
|
|
7965
8020
|
// Try to extract provider from class name
|
|
7966
8021
|
const className = llm?.constructor?.name || '';
|
|
@@ -8003,7 +8058,7 @@ const executeChain = async ({ llm, prompt, variables, parser, provider, endpoint
|
|
|
8003
8058
|
});
|
|
8004
8059
|
}
|
|
8005
8060
|
// Extract LLM info for error reporting if not provided
|
|
8006
|
-
const llmInfo = extractLlmInfo(llm);
|
|
8061
|
+
const llmInfo = extractLlmInfo$1(llm);
|
|
8007
8062
|
const effectiveProvider = provider || llmInfo.provider;
|
|
8008
8063
|
const effectiveEndpoint = endpoint || llmInfo.endpoint;
|
|
8009
8064
|
try {
|
|
@@ -14588,6 +14643,11 @@ const options$8 = {
|
|
|
14588
14643
|
type: 'boolean',
|
|
14589
14644
|
default: false,
|
|
14590
14645
|
},
|
|
14646
|
+
strictSplit: {
|
|
14647
|
+
description: 'Fail loudly if the split planner exhausts its retry budget with an invalid plan (otherwise falls back to a single combined commit).',
|
|
14648
|
+
type: 'boolean',
|
|
14649
|
+
default: false,
|
|
14650
|
+
},
|
|
14591
14651
|
};
|
|
14592
14652
|
const builder$8 = (yargs) => {
|
|
14593
14653
|
return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
|
|
@@ -15476,6 +15536,53 @@ function dropEmptyGroups(plan) {
|
|
|
15476
15536
|
}
|
|
15477
15537
|
return { ...plan, groups: surviving };
|
|
15478
15538
|
}
|
|
15539
|
+
/**
|
|
15540
|
+
* Construct a trivially-valid single-group plan covering every staged
|
|
15541
|
+
* file. Used as the fallback when the LLM exhausts its retry budget
|
|
15542
|
+
* with an invalid plan — turning a hard failure into a usable
|
|
15543
|
+
* (if degraded) outcome.
|
|
15544
|
+
*
|
|
15545
|
+
* Properties of the returned plan:
|
|
15546
|
+
*
|
|
15547
|
+
* - Exactly one group.
|
|
15548
|
+
* - Every staged file appears in that group's `files[]`. No hunks
|
|
15549
|
+
* are claimed, so any hunk inventory is irrelevant to the plan's
|
|
15550
|
+
* validity.
|
|
15551
|
+
* - By construction: no duplicates, no missing files, no mixed
|
|
15552
|
+
* mode, no phantom hunks. `getPlanValidationIssues` returns an
|
|
15553
|
+
* empty issue set.
|
|
15554
|
+
*
|
|
15555
|
+
* The group's `rationale` carries the reason text the caller wants
|
|
15556
|
+
* to expose to the UI (typically "model exhausted N attempts; last
|
|
15557
|
+
* issues were …"). The `body` carries a short note that survives
|
|
15558
|
+
* into the commit message body so a user who applies without editing
|
|
15559
|
+
* has the context recorded in git history.
|
|
15560
|
+
*
|
|
15561
|
+
* `title` defaults to a generic conventional-commits-compatible
|
|
15562
|
+
* `chore: combined commit` — bland on purpose. Real commit messaging
|
|
15563
|
+
* is the user's job at the compose / apply step.
|
|
15564
|
+
*
|
|
15565
|
+
* The plan is NOT linked to the LLM by construction. If the model
|
|
15566
|
+
* can't produce a valid split, the user still gets one apply-able
|
|
15567
|
+
* commit instead of a thrown error and a still-staged worktree.
|
|
15568
|
+
*/
|
|
15569
|
+
function buildSplitPlanFallback(staged, options = {}) {
|
|
15570
|
+
const files = staged.map((change) => change.filePath);
|
|
15571
|
+
const reasonLine = options.reason
|
|
15572
|
+
? ` Reason: ${options.reason}`
|
|
15573
|
+
: '';
|
|
15574
|
+
return {
|
|
15575
|
+
groups: [
|
|
15576
|
+
{
|
|
15577
|
+
title: 'chore: combined commit',
|
|
15578
|
+
body: 'Auto-generated single-commit fallback after the split planner could not produce a valid multi-group plan. Edit before applying if you want a more specific message; press `r` to re-roll the planner if a different model might do better.',
|
|
15579
|
+
rationale: `Fallback plan — every staged file in one commit because the LLM could not produce a valid multi-group split.${reasonLine}`,
|
|
15580
|
+
files,
|
|
15581
|
+
hunks: [],
|
|
15582
|
+
},
|
|
15583
|
+
],
|
|
15584
|
+
};
|
|
15585
|
+
}
|
|
15479
15586
|
function formatPlanValidationFeedback(issues) {
|
|
15480
15587
|
const sections = [];
|
|
15481
15588
|
if (issues.unknownFiles.length) {
|
|
@@ -15512,7 +15619,7 @@ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
|
|
|
15512
15619
|
* into the same prompt template (`previous_attempt_feedback` slot) so the model can
|
|
15513
15620
|
* fix its own mistakes without re-running pre-processing.
|
|
15514
15621
|
*/
|
|
15515
|
-
async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
|
|
15622
|
+
async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, strict = false, }) {
|
|
15516
15623
|
let lastIssues = null;
|
|
15517
15624
|
let attempt = 0;
|
|
15518
15625
|
while (attempt < maxAttempts) {
|
|
@@ -15582,9 +15689,42 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
|
|
|
15582
15689
|
logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
|
|
15583
15690
|
}
|
|
15584
15691
|
}
|
|
15585
|
-
|
|
15586
|
-
?
|
|
15587
|
-
:
|
|
15692
|
+
const issuesSummary = lastIssues
|
|
15693
|
+
? formatPlanValidationIssuesError(lastIssues)
|
|
15694
|
+
: 'no captured validator issues';
|
|
15695
|
+
// Strict mode: restore the pre-#1005 behaviour. Callers that pass
|
|
15696
|
+
// `strict: true` (and CLI users via `--strict-split`) want explicit
|
|
15697
|
+
// failure rather than the degraded fallback.
|
|
15698
|
+
if (strict) {
|
|
15699
|
+
throw new Error(lastIssues
|
|
15700
|
+
? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${issuesSummary}`
|
|
15701
|
+
: `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
|
|
15702
|
+
}
|
|
15703
|
+
// Default: hand back a trivially-valid single-group fallback. The
|
|
15704
|
+
// caller's apply / preview surface should treat the `fallback` flag
|
|
15705
|
+
// as a signal to nudge the user (it's strictly better than a hard
|
|
15706
|
+
// failure with the staged set still on disk, but it's still a
|
|
15707
|
+
// degraded outcome compared to a real multi-group split).
|
|
15708
|
+
const reason = `LLM exhausted ${maxAttempts} planning attempts; final validator issues: ${issuesSummary}`;
|
|
15709
|
+
if (logger) {
|
|
15710
|
+
logger.verbose(`Plan attempts exhausted — falling back to a single-group plan. ${reason}`, { color: 'yellow' });
|
|
15711
|
+
}
|
|
15712
|
+
return {
|
|
15713
|
+
plan: buildSplitPlanFallback(staged, { reason: issuesSummary }),
|
|
15714
|
+
attempts: maxAttempts,
|
|
15715
|
+
fallback: {
|
|
15716
|
+
reason,
|
|
15717
|
+
lastIssues: lastIssues ?? {
|
|
15718
|
+
unknownFiles: [],
|
|
15719
|
+
duplicateFiles: [],
|
|
15720
|
+
unknownHunks: [],
|
|
15721
|
+
duplicateHunks: [],
|
|
15722
|
+
mixedFiles: [],
|
|
15723
|
+
partiallyCoveredFiles: [],
|
|
15724
|
+
missingFiles: [],
|
|
15725
|
+
},
|
|
15726
|
+
},
|
|
15727
|
+
};
|
|
15588
15728
|
}
|
|
15589
15729
|
|
|
15590
15730
|
/**
|
|
@@ -15786,7 +15926,7 @@ async function applyPatchToIndex$1(patch, git) {
|
|
|
15786
15926
|
child.stdin.end();
|
|
15787
15927
|
});
|
|
15788
15928
|
}
|
|
15789
|
-
async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, }) {
|
|
15929
|
+
async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, fallback, }) {
|
|
15790
15930
|
validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
|
|
15791
15931
|
assertNoUnstagedOverlap(plan, changes, hunkInventory);
|
|
15792
15932
|
// Defensive: drop any group with empty files[] AND empty hunks[].
|
|
@@ -15889,11 +16029,13 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
|
|
|
15889
16029
|
return {
|
|
15890
16030
|
commitHashes,
|
|
15891
16031
|
message: `Created ${commitHashes.length} of ${applicableGroups.length} planned commit(s). Failed: ${partial}`,
|
|
16032
|
+
fallback,
|
|
15892
16033
|
};
|
|
15893
16034
|
}
|
|
15894
16035
|
return {
|
|
15895
16036
|
commitHashes,
|
|
15896
16037
|
message: `Created ${commitHashes.length} split commit(s).`,
|
|
16038
|
+
fallback,
|
|
15897
16039
|
};
|
|
15898
16040
|
}
|
|
15899
16041
|
/**
|
|
@@ -15991,7 +16133,7 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15991
16133
|
}
|
|
15992
16134
|
const resolvedPlanLlm = planLlm ?? llm;
|
|
15993
16135
|
const resolvedPlanModel = planService?.model ?? config.service.model;
|
|
15994
|
-
const { plan } = await generateValidatedCommitSplitPlan({
|
|
16136
|
+
const { plan, fallback } = await generateValidatedCommitSplitPlan({
|
|
15995
16137
|
llm: resolvedPlanLlm,
|
|
15996
16138
|
prompt: COMMIT_SPLIT_PROMPT,
|
|
15997
16139
|
variables: {
|
|
@@ -16014,8 +16156,12 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
16014
16156
|
conventional: useConventional,
|
|
16015
16157
|
},
|
|
16016
16158
|
maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
|
|
16159
|
+
// Honour `--strict-split` (CLI) or `strictSplit` (config). When set,
|
|
16160
|
+
// the planner reverts to the pre-#1005 behaviour of throwing on
|
|
16161
|
+
// exhaustion instead of returning the single-group fallback.
|
|
16162
|
+
strict: Boolean(argv.strictSplit ?? config.strictSplit),
|
|
16017
16163
|
});
|
|
16018
|
-
return { plan, context: { changes, hunkInventory } };
|
|
16164
|
+
return { plan, context: { changes, hunkInventory }, fallback };
|
|
16019
16165
|
}
|
|
16020
16166
|
async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
|
|
16021
16167
|
const result = await prepareCommitSplitPlan({
|
|
@@ -16031,7 +16177,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
|
|
|
16031
16177
|
if ('empty' in result) {
|
|
16032
16178
|
return 'No staged changes found.';
|
|
16033
16179
|
}
|
|
16034
|
-
const { plan, context } = result;
|
|
16180
|
+
const { plan, context, fallback } = result;
|
|
16035
16181
|
if (argv.apply) {
|
|
16036
16182
|
const applied = await applyCommitSplitPlan({
|
|
16037
16183
|
plan,
|
|
@@ -16040,9 +16186,24 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
|
|
|
16040
16186
|
git,
|
|
16041
16187
|
logger,
|
|
16042
16188
|
noVerify: argv.noVerify || config.noVerify || false,
|
|
16189
|
+
fallback,
|
|
16043
16190
|
});
|
|
16191
|
+
if (applied.fallback) {
|
|
16192
|
+
return [
|
|
16193
|
+
`Note: applied the single-commit fallback (${applied.fallback.reason}).`,
|
|
16194
|
+
applied.message,
|
|
16195
|
+
].join('\n');
|
|
16196
|
+
}
|
|
16044
16197
|
return applied.message;
|
|
16045
16198
|
}
|
|
16199
|
+
if (fallback) {
|
|
16200
|
+
return [
|
|
16201
|
+
`Note: showing the single-commit fallback plan (${fallback.reason}).`,
|
|
16202
|
+
'Re-run with a stronger model or use --strict-split to surface the planner error.',
|
|
16203
|
+
'',
|
|
16204
|
+
formatCommitSplitPlan(plan),
|
|
16205
|
+
].join('\n');
|
|
16206
|
+
}
|
|
16046
16207
|
return formatCommitSplitPlan(plan);
|
|
16047
16208
|
}
|
|
16048
16209
|
|
|
@@ -20076,16 +20237,43 @@ function applyCommitComposeAction(state, action) {
|
|
|
20076
20237
|
field: state.field === 'summary' ? 'body' : 'summary',
|
|
20077
20238
|
};
|
|
20078
20239
|
case 'setEditing':
|
|
20240
|
+
// Audit finding #12: defensively clear `streamingPreview` when
|
|
20241
|
+
// editing toggles off AND no draft is in flight. The current
|
|
20242
|
+
// input pipeline never triggers this combination, but the
|
|
20243
|
+
// reducer is the source of truth — if a future code path
|
|
20244
|
+
// toggles editing off mid-stream, the preview shouldn't linger
|
|
20245
|
+
// below an idle compose panel.
|
|
20079
20246
|
return {
|
|
20080
20247
|
...state,
|
|
20081
20248
|
editing: action.value,
|
|
20249
|
+
streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
|
|
20082
20250
|
};
|
|
20083
20251
|
case 'setLoading':
|
|
20252
|
+
// Clearing loading also clears any in-flight streaming preview;
|
|
20253
|
+
// the preview's whole purpose is to fill the wait window. Once
|
|
20254
|
+
// the wait ends (success OR failure), the preview is stale.
|
|
20084
20255
|
return {
|
|
20085
20256
|
...state,
|
|
20086
20257
|
loading: action.value,
|
|
20258
|
+
streamingPreview: action.value ? state.streamingPreview : undefined,
|
|
20087
20259
|
};
|
|
20088
20260
|
case 'setDraft':
|
|
20261
|
+
// Audit finding #7: if the user has typed content in summary or
|
|
20262
|
+
// body, the AI draft would silently clobber their work with no
|
|
20263
|
+
// undo. Route the result to `pendingAiDraft` instead and surface
|
|
20264
|
+
// a confirmation message; the user accepts with `R` (replace)
|
|
20265
|
+
// or dismisses with Esc. Empty fields = safe to replace as
|
|
20266
|
+
// before, since there's nothing to lose.
|
|
20267
|
+
if (state.summary.trim() || state.body.trim()) {
|
|
20268
|
+
return {
|
|
20269
|
+
...state,
|
|
20270
|
+
loading: false,
|
|
20271
|
+
streamingPreview: undefined,
|
|
20272
|
+
pendingAiDraft: action.value,
|
|
20273
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20274
|
+
details: undefined,
|
|
20275
|
+
};
|
|
20276
|
+
}
|
|
20089
20277
|
// No `message` here — the loader → filled fields are the confirmation
|
|
20090
20278
|
// that the AI generated something. A lingering "AI draft ready for
|
|
20091
20279
|
// editing" line in the panel reads as stale state. The runtime still
|
|
@@ -20099,6 +20287,8 @@ function applyCommitComposeAction(state, action) {
|
|
|
20099
20287
|
loading: false,
|
|
20100
20288
|
message: undefined,
|
|
20101
20289
|
details: undefined,
|
|
20290
|
+
streamingPreview: undefined,
|
|
20291
|
+
pendingAiDraft: undefined,
|
|
20102
20292
|
};
|
|
20103
20293
|
case 'setResult':
|
|
20104
20294
|
return {
|
|
@@ -20106,6 +20296,57 @@ function applyCommitComposeAction(state, action) {
|
|
|
20106
20296
|
loading: false,
|
|
20107
20297
|
message: action.message,
|
|
20108
20298
|
details: action.details,
|
|
20299
|
+
streamingPreview: undefined,
|
|
20300
|
+
};
|
|
20301
|
+
case 'setStreamingPreview':
|
|
20302
|
+
// Per-chunk live-preview update. Fires from the streaming
|
|
20303
|
+
// workflow's onChunk callback; the renderer turns it into a
|
|
20304
|
+
// last-N-lines panel below the loading line. Pass `undefined`
|
|
20305
|
+
// to explicitly clear (the workflow does this on completion
|
|
20306
|
+
// alongside the `setDraft` / `setResult` dispatch).
|
|
20307
|
+
return {
|
|
20308
|
+
...state,
|
|
20309
|
+
streamingPreview: action.value,
|
|
20310
|
+
};
|
|
20311
|
+
case 'setPendingAiDraft':
|
|
20312
|
+
// Audit finding #7: route the AI draft here (instead of straight
|
|
20313
|
+
// to summary/body via `setDraft`) when the user has unsaved
|
|
20314
|
+
// typing the draft would clobber. The dispatcher does the
|
|
20315
|
+
// user-content check; this reducer just stashes the draft and
|
|
20316
|
+
// surfaces a message inviting the user to accept or dismiss.
|
|
20317
|
+
return {
|
|
20318
|
+
...state,
|
|
20319
|
+
loading: false,
|
|
20320
|
+
streamingPreview: undefined,
|
|
20321
|
+
pendingAiDraft: action.value,
|
|
20322
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20323
|
+
details: undefined,
|
|
20324
|
+
};
|
|
20325
|
+
case 'acceptPendingAiDraft':
|
|
20326
|
+
// Swap the pending draft into the editable fields and clear it.
|
|
20327
|
+
// Mirrors `setDraft`'s field positioning (focus on summary,
|
|
20328
|
+
// editing on) so the user lands in the same place whether they
|
|
20329
|
+
// accepted immediately or after deliberation.
|
|
20330
|
+
if (!state.pendingAiDraft)
|
|
20331
|
+
return state;
|
|
20332
|
+
return {
|
|
20333
|
+
...state,
|
|
20334
|
+
...splitCommitDraft(state.pendingAiDraft),
|
|
20335
|
+
field: 'summary',
|
|
20336
|
+
editing: true,
|
|
20337
|
+
loading: false,
|
|
20338
|
+
message: undefined,
|
|
20339
|
+
details: undefined,
|
|
20340
|
+
streamingPreview: undefined,
|
|
20341
|
+
pendingAiDraft: undefined,
|
|
20342
|
+
};
|
|
20343
|
+
case 'dismissPendingAiDraft':
|
|
20344
|
+
// User chose to keep their typing; drop the AI draft.
|
|
20345
|
+
return {
|
|
20346
|
+
...state,
|
|
20347
|
+
pendingAiDraft: undefined,
|
|
20348
|
+
message: undefined,
|
|
20349
|
+
details: undefined,
|
|
20109
20350
|
};
|
|
20110
20351
|
case 'reset':
|
|
20111
20352
|
// Drop message/details too — the post-commit "Created commit ..."
|
|
@@ -20179,6 +20420,233 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
|
|
|
20179
20420
|
}
|
|
20180
20421
|
}
|
|
20181
20422
|
|
|
20423
|
+
/**
|
|
20424
|
+
* Same provider / endpoint best-effort extraction `executeChain` uses,
|
|
20425
|
+
* duplicated here rather than imported so the streaming module doesn't
|
|
20426
|
+
* pull on `executeChain`'s implementation. If both helpers ever need to
|
|
20427
|
+
* share more, factor this out to a shared `llmInfo.ts`.
|
|
20428
|
+
*/
|
|
20429
|
+
function extractLlmInfo(llm) {
|
|
20430
|
+
const info = {};
|
|
20431
|
+
const className = llm?.constructor?.name || '';
|
|
20432
|
+
if (className.includes('Ollama')) {
|
|
20433
|
+
info.provider = 'ollama';
|
|
20434
|
+
if ('lc_kwargs' in llm && typeof llm.lc_kwargs === 'object' && llm.lc_kwargs !== null) {
|
|
20435
|
+
const kwargs = llm.lc_kwargs;
|
|
20436
|
+
if (typeof kwargs.baseUrl === 'string') {
|
|
20437
|
+
info.endpoint = kwargs.baseUrl;
|
|
20438
|
+
}
|
|
20439
|
+
}
|
|
20440
|
+
}
|
|
20441
|
+
else if (className.includes('OpenAI')) {
|
|
20442
|
+
info.provider = 'openai';
|
|
20443
|
+
}
|
|
20444
|
+
else if (className.includes('Anthropic')) {
|
|
20445
|
+
info.provider = 'anthropic';
|
|
20446
|
+
}
|
|
20447
|
+
return info;
|
|
20448
|
+
}
|
|
20449
|
+
/**
|
|
20450
|
+
* Coerce one streamed chunk into its text fragment. LangChain's
|
|
20451
|
+
* `prompt.pipe(llm).stream(...)` yields `BaseMessageChunk` instances
|
|
20452
|
+
* whose `.content` is sometimes a string and sometimes an array of
|
|
20453
|
+
* content parts (multi-modal models, tool calls). We only care about
|
|
20454
|
+
* the textual delta here; non-text parts are silently dropped because
|
|
20455
|
+
* phase 1's surfaces (stdout + status-line copy) can't render them
|
|
20456
|
+
* anyway.
|
|
20457
|
+
*/
|
|
20458
|
+
function coerceChunkText(messageChunk) {
|
|
20459
|
+
if (typeof messageChunk === 'string')
|
|
20460
|
+
return messageChunk;
|
|
20461
|
+
if (messageChunk && typeof messageChunk === 'object' && 'content' in messageChunk) {
|
|
20462
|
+
const content = messageChunk.content;
|
|
20463
|
+
if (typeof content === 'string')
|
|
20464
|
+
return content;
|
|
20465
|
+
if (Array.isArray(content)) {
|
|
20466
|
+
// Multi-part content array — concatenate the text parts only.
|
|
20467
|
+
return content
|
|
20468
|
+
.map((part) => {
|
|
20469
|
+
if (typeof part === 'string')
|
|
20470
|
+
return part;
|
|
20471
|
+
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
|
|
20472
|
+
return part.text;
|
|
20473
|
+
}
|
|
20474
|
+
return '';
|
|
20475
|
+
})
|
|
20476
|
+
.join('');
|
|
20477
|
+
}
|
|
20478
|
+
}
|
|
20479
|
+
return '';
|
|
20480
|
+
}
|
|
20481
|
+
/**
|
|
20482
|
+
* Streaming variant of `executeChain`. Pipes the prompt into the LLM,
|
|
20483
|
+
* consumes the resulting async iterable, fires `onChunk` with each text
|
|
20484
|
+
* fragment as it arrives, and runs the supplied parser against the
|
|
20485
|
+
* fully-accumulated text on completion. Returns the parsed result.
|
|
20486
|
+
*
|
|
20487
|
+
* Why a separate function instead of an `onChunk?` flag on
|
|
20488
|
+
* `executeChain`? Two reasons:
|
|
20489
|
+
*
|
|
20490
|
+
* 1. The two paths have meaningfully different failure modes — a
|
|
20491
|
+
* half-streamed result can be salvaged with a best-effort parse;
|
|
20492
|
+
* an `invoke()` failure can't. Separate functions let each handle
|
|
20493
|
+
* its own error shape cleanly.
|
|
20494
|
+
* 2. Callers should make an explicit choice about whether they want
|
|
20495
|
+
* streaming. Adding it as an opt-in flag on `executeChain` makes
|
|
20496
|
+
* it tempting to plumb `onChunk` from random surfaces; a separate
|
|
20497
|
+
* helper makes the call site say "yes, this needs streaming."
|
|
20498
|
+
*
|
|
20499
|
+
* No automatic fallback to non-streaming `executeChain` when streaming
|
|
20500
|
+
* fails — by design. Callers that want fallback should `catch` this
|
|
20501
|
+
* function and call `executeChain` themselves. Keeps the helper focused
|
|
20502
|
+
* on the streaming path and the fallback policy explicit at the call
|
|
20503
|
+
* site (different commands may want different fallback strategies).
|
|
20504
|
+
*/
|
|
20505
|
+
async function executeChainStreaming({ llm, prompt, variables, parser, onChunk, signal, provider, endpoint, logger, tokenizer, metadata, }) {
|
|
20506
|
+
validateRequired(llm, 'llm', 'executeChainStreaming');
|
|
20507
|
+
validateRequired(prompt, 'prompt', 'executeChainStreaming');
|
|
20508
|
+
validateRequired(variables, 'variables', 'executeChainStreaming');
|
|
20509
|
+
validateRequired(parser, 'parser', 'executeChainStreaming');
|
|
20510
|
+
validateRequired(onChunk, 'onChunk', 'executeChainStreaming');
|
|
20511
|
+
if (typeof variables !== 'object' || Array.isArray(variables)) {
|
|
20512
|
+
throw new LangChainExecutionError('executeChainStreaming: Variables must be a non-array object', { variables, type: typeof variables, isArray: Array.isArray(variables) });
|
|
20513
|
+
}
|
|
20514
|
+
// Pre-flight abort check (#881 phase 3). Callers that ran the cancel
|
|
20515
|
+
// path before reaching here shouldn't pay for prompt rendering or
|
|
20516
|
+
// request setup. Match the contract `chain.stream(..., { signal })`
|
|
20517
|
+
// would have honoured — throw `LangChainCancelledError` rather than
|
|
20518
|
+
// a bare `AbortError`.
|
|
20519
|
+
if (signal?.aborted) {
|
|
20520
|
+
throw new LangChainCancelledError('executeChainStreaming: Aborted before stream opened', '');
|
|
20521
|
+
}
|
|
20522
|
+
const llmInfo = extractLlmInfo(llm);
|
|
20523
|
+
const effectiveProvider = provider || llmInfo.provider;
|
|
20524
|
+
const effectiveEndpoint = endpoint || llmInfo.endpoint;
|
|
20525
|
+
let accumulated = '';
|
|
20526
|
+
try {
|
|
20527
|
+
const renderedPrompt = await prompt.format(variables);
|
|
20528
|
+
const promptTokens = estimatePromptTokens(tokenizer, renderedPrompt);
|
|
20529
|
+
const chain = prompt.pipe(llm);
|
|
20530
|
+
const startedAt = Date.now();
|
|
20531
|
+
// Forward the signal into LangChain's RunnableConfig. The HTTP
|
|
20532
|
+
// transport (openai / anthropic / ollama clients) honours it and
|
|
20533
|
+
// tears down the connection rather than waiting for the model to
|
|
20534
|
+
// finish. The async iterator throws an AbortError that we
|
|
20535
|
+
// classify below.
|
|
20536
|
+
const stream = await chain.stream(variables, signal ? { signal } : undefined);
|
|
20537
|
+
let chunkCount = 0;
|
|
20538
|
+
let callbackFailureCount = 0;
|
|
20539
|
+
// Audit finding #13: cap consecutive callback failures so a
|
|
20540
|
+
// genuinely broken render handler can't tie up the LLM call
|
|
20541
|
+
// silently for the user's entire wait. Five strikes (out of an
|
|
20542
|
+
// expected ~50-500 chunks for a normal commit message) is enough
|
|
20543
|
+
// to ride out a transient blip but small enough to bail before
|
|
20544
|
+
// the user finishes waiting on a useless stream.
|
|
20545
|
+
const MAX_CALLBACK_FAILURES = 5;
|
|
20546
|
+
for await (const messageChunk of stream) {
|
|
20547
|
+
const text = coerceChunkText(messageChunk);
|
|
20548
|
+
if (!text)
|
|
20549
|
+
continue;
|
|
20550
|
+
accumulated += text;
|
|
20551
|
+
chunkCount += 1;
|
|
20552
|
+
try {
|
|
20553
|
+
onChunk({ text, accumulated });
|
|
20554
|
+
// Successful callback resets the consecutive-failure counter —
|
|
20555
|
+
// we only bail on a STREAK of failures, not on isolated ones.
|
|
20556
|
+
callbackFailureCount = 0;
|
|
20557
|
+
}
|
|
20558
|
+
catch (callbackError) {
|
|
20559
|
+
// Deliberately swallow callback errors so a bad render handler
|
|
20560
|
+
// can't tank the entire LLM call. Log at verbose so users with
|
|
20561
|
+
// verbose mode on can still see what happened.
|
|
20562
|
+
callbackFailureCount += 1;
|
|
20563
|
+
logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
|
|
20564
|
+
if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
|
|
20565
|
+
logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
|
|
20566
|
+
throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
|
|
20567
|
+
}
|
|
20568
|
+
}
|
|
20569
|
+
}
|
|
20570
|
+
if (!accumulated) {
|
|
20571
|
+
throw new LangChainExecutionError('executeChainStreaming: Stream completed with no text chunks', { variables, promptInputVariables: prompt.inputVariables });
|
|
20572
|
+
}
|
|
20573
|
+
const result = (await parser.invoke(accumulated));
|
|
20574
|
+
const elapsedMs = Date.now() - startedAt;
|
|
20575
|
+
logLlmCall(logger, {
|
|
20576
|
+
task: metadata?.task || 'chain-streaming',
|
|
20577
|
+
provider: effectiveProvider,
|
|
20578
|
+
parserType: parser.constructor.name,
|
|
20579
|
+
variableKeys: Object.keys(variables),
|
|
20580
|
+
promptTokens,
|
|
20581
|
+
elapsedMs,
|
|
20582
|
+
// Surfaced in observability so consumers can spot the streaming
|
|
20583
|
+
// path in their logs without correlating across tools. `chunks`
|
|
20584
|
+
// doubles as a sanity check (a streaming call that delivered 1
|
|
20585
|
+
// chunk is functionally identical to a non-streaming one).
|
|
20586
|
+
streamed: true,
|
|
20587
|
+
streamChunks: chunkCount,
|
|
20588
|
+
...metadata,
|
|
20589
|
+
});
|
|
20590
|
+
if (result === null || result === undefined) {
|
|
20591
|
+
throw new LangChainExecutionError('executeChainStreaming: Parser returned null or undefined from streamed text', {
|
|
20592
|
+
variables,
|
|
20593
|
+
promptInputVariables: prompt.inputVariables,
|
|
20594
|
+
accumulatedLength: accumulated.length,
|
|
20595
|
+
});
|
|
20596
|
+
}
|
|
20597
|
+
return result;
|
|
20598
|
+
}
|
|
20599
|
+
catch (error) {
|
|
20600
|
+
// Cancellation classifier (#881 phase 3). Three signals: an
|
|
20601
|
+
// explicitly aborted user signal (post-throw check) or a thrown
|
|
20602
|
+
// `AbortError` from the standard DOM API. Either means "user
|
|
20603
|
+
// wanted out," not "the call failed." Wrap the raw error so
|
|
20604
|
+
// callers can pattern-match on `LangChainCancelledError` and
|
|
20605
|
+
// carry the partial accumulated text in case the caller wants
|
|
20606
|
+
// to salvage anything.
|
|
20607
|
+
//
|
|
20608
|
+
// Audit finding #8: an earlier implementation also fell back to
|
|
20609
|
+
// `error.message.includes('aborted')` as a third signal. That
|
|
20610
|
+
// substring heuristic is footgun-shaped — legitimate provider
|
|
20611
|
+
// errors ("model not aborted properly", future API copy) would
|
|
20612
|
+
// misclassify as user cancels. Dropped; rely on the structured
|
|
20613
|
+
// signal (`signal.aborted`) and the standard error class
|
|
20614
|
+
// (`name === 'AbortError'`).
|
|
20615
|
+
const aborted = signal?.aborted ||
|
|
20616
|
+
(error instanceof Error && error.name === 'AbortError');
|
|
20617
|
+
if (aborted) {
|
|
20618
|
+
throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
|
|
20619
|
+
provider: effectiveProvider,
|
|
20620
|
+
endpoint: effectiveEndpoint,
|
|
20621
|
+
});
|
|
20622
|
+
}
|
|
20623
|
+
if (error instanceof LangChainExecutionError ||
|
|
20624
|
+
error instanceof LangChainNetworkError ||
|
|
20625
|
+
error instanceof LangChainCancelledError) {
|
|
20626
|
+
throw error;
|
|
20627
|
+
}
|
|
20628
|
+
if (error instanceof Error && isNetworkError(error)) {
|
|
20629
|
+
throw new LangChainNetworkError(error.message, effectiveEndpoint, effectiveProvider, {
|
|
20630
|
+
originalError: error.name,
|
|
20631
|
+
originalMessage: error.message,
|
|
20632
|
+
stack: error.stack,
|
|
20633
|
+
promptInputVariables: prompt.inputVariables,
|
|
20634
|
+
variableKeys: Object.keys(variables),
|
|
20635
|
+
parserType: parser.constructor.name,
|
|
20636
|
+
streamed: true,
|
|
20637
|
+
});
|
|
20638
|
+
}
|
|
20639
|
+
handleLangChainError(error, 'executeChainStreaming: Stream execution failed', {
|
|
20640
|
+
promptInputVariables: prompt.inputVariables,
|
|
20641
|
+
variableKeys: Object.keys(variables),
|
|
20642
|
+
parserType: parser.constructor.name,
|
|
20643
|
+
provider: effectiveProvider,
|
|
20644
|
+
endpoint: effectiveEndpoint,
|
|
20645
|
+
streamed: true,
|
|
20646
|
+
});
|
|
20647
|
+
}
|
|
20648
|
+
}
|
|
20649
|
+
|
|
20182
20650
|
const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
|
|
20183
20651
|
|
|
20184
20652
|
REQUIRED JSON FORMAT:
|
|
@@ -20203,7 +20671,45 @@ IMPORTANT RULES:
|
|
|
20203
20671
|
* are surfaced as `validationErrors`/`warnings` rather than driving an
|
|
20204
20672
|
* interactive retry flow — the TUI can re-invoke or let the user edit.
|
|
20205
20673
|
*/
|
|
20206
|
-
|
|
20674
|
+
/**
|
|
20675
|
+
* Fallback parser shared between the non-streaming
|
|
20676
|
+
* `executeChainWithSchema` call and the streaming path (#881 phase 2).
|
|
20677
|
+
*
|
|
20678
|
+
* Extracted from the inline `fallbackParser` option so the streaming
|
|
20679
|
+
* path can use the same lossy-but-permissive recovery for accumulated
|
|
20680
|
+
* text. Strips markdown code fences, attempts strict JSON parse, and
|
|
20681
|
+
* falls back to "first line is title, rest is body" when JSON parsing
|
|
20682
|
+
* fails entirely.
|
|
20683
|
+
*
|
|
20684
|
+
* Returned shape always satisfies the schema's structural requirements
|
|
20685
|
+
* (`title` + `body` strings) but the *content* may be the last-ditch
|
|
20686
|
+
* "Auto-generated commit" placeholder. Callers should treat this as a
|
|
20687
|
+
* best-effort salvage, not a parse confirmation.
|
|
20688
|
+
*/
|
|
20689
|
+
function salvageCommitMessageFromText(text) {
|
|
20690
|
+
try {
|
|
20691
|
+
let cleanText = text.trim();
|
|
20692
|
+
const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
20693
|
+
if (codeBlockMatch && codeBlockMatch[1]) {
|
|
20694
|
+
cleanText = codeBlockMatch[1].trim();
|
|
20695
|
+
}
|
|
20696
|
+
const parsed = JSON.parse(cleanText);
|
|
20697
|
+
if (parsed && typeof parsed === 'object' &&
|
|
20698
|
+
typeof parsed.title === 'string' &&
|
|
20699
|
+
typeof parsed.body === 'string' &&
|
|
20700
|
+
parsed.title.length > 0) {
|
|
20701
|
+
return parsed;
|
|
20702
|
+
}
|
|
20703
|
+
}
|
|
20704
|
+
catch {
|
|
20705
|
+
// fall through to line-split salvage
|
|
20706
|
+
}
|
|
20707
|
+
return {
|
|
20708
|
+
title: text.split('\n')[0] || 'Auto-generated commit',
|
|
20709
|
+
body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
|
|
20710
|
+
};
|
|
20711
|
+
}
|
|
20712
|
+
async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), onStreamChunk, signal, }) {
|
|
20207
20713
|
const config = loadConfig(argv);
|
|
20208
20714
|
const key = getApiKeyForModel(config);
|
|
20209
20715
|
const { provider } = getModelAndProviderFromConfig(config);
|
|
@@ -20346,42 +20852,137 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20346
20852
|
tokenizer,
|
|
20347
20853
|
maxTokens: config.service.tokenLimit || 2048,
|
|
20348
20854
|
});
|
|
20349
|
-
|
|
20350
|
-
|
|
20351
|
-
|
|
20352
|
-
|
|
20353
|
-
|
|
20354
|
-
|
|
20355
|
-
|
|
20356
|
-
|
|
20357
|
-
|
|
20358
|
-
|
|
20359
|
-
|
|
20360
|
-
|
|
20361
|
-
|
|
20362
|
-
|
|
20363
|
-
|
|
20364
|
-
|
|
20365
|
-
|
|
20366
|
-
|
|
20367
|
-
|
|
20368
|
-
|
|
20369
|
-
|
|
20370
|
-
|
|
20371
|
-
|
|
20372
|
-
|
|
20373
|
-
|
|
20855
|
+
// Streaming path (#881 phase 2). Active when the caller supplied
|
|
20856
|
+
// an `onStreamChunk` AND the config opted in. Only the FIRST
|
|
20857
|
+
// attempt streams; the commitlint-retry attempt (attempt === 2)
|
|
20858
|
+
// and the existing executeChainWithSchema retry loop run
|
|
20859
|
+
// non-streaming so we keep the schema-validated retry as the
|
|
20860
|
+
// backstop when the streamed text can't be salvaged.
|
|
20861
|
+
const streamingEnabled = Boolean(onStreamChunk && config.service.streaming?.enabled);
|
|
20862
|
+
const shouldStreamThisAttempt = streamingEnabled && attempt === 1;
|
|
20863
|
+
let commitMsg;
|
|
20864
|
+
if (shouldStreamThisAttempt && onStreamChunk) {
|
|
20865
|
+
// The streaming chain bypasses the schema parser during the
|
|
20866
|
+
// stream itself (no streaming-aware JSON parser today) and
|
|
20867
|
+
// delivers the raw accumulated text to a no-op `parser.invoke`.
|
|
20868
|
+
// We then salvage the structured result via the same lossy
|
|
20869
|
+
// recovery the non-streaming fallbackParser uses. If the
|
|
20870
|
+
// salvager produces a plausible draft, we use it. Otherwise we
|
|
20871
|
+
// fall through to executeChainWithSchema below for a real
|
|
20872
|
+
// schema-validated retry — paying for a second LLM call only
|
|
20873
|
+
// on the edge case where the streamed output is unsalvageable.
|
|
20874
|
+
const streamingParser = createSchemaParser(schema, llm);
|
|
20875
|
+
// Capture the final accumulated text out-of-band so we can
|
|
20876
|
+
// attempt salvage if the parser throws on completion (audit
|
|
20877
|
+
// finding #1). Updated on every chunk; the last value is
|
|
20878
|
+
// whatever the stream produced before the parser ran. Empty
|
|
20879
|
+
// string when streaming throws before any chunks arrived.
|
|
20880
|
+
let streamedAccumulated = '';
|
|
20881
|
+
let salvaged;
|
|
20882
|
+
try {
|
|
20883
|
+
// `executeChainStreaming` runs the parser on the accumulated
|
|
20884
|
+
// text at completion. StructuredOutputParser will throw when
|
|
20885
|
+
// the model produced unparseable JSON — we catch that below
|
|
20886
|
+
// and salvage manually. The happy-path zod-validated object
|
|
20887
|
+
// becomes our commitMsg.
|
|
20888
|
+
commitMsg = await executeChainStreaming({
|
|
20889
|
+
llm,
|
|
20890
|
+
prompt,
|
|
20891
|
+
variables: budgetedPrompt.variables,
|
|
20892
|
+
parser: streamingParser,
|
|
20893
|
+
onChunk: ({ text, accumulated }) => {
|
|
20894
|
+
streamedAccumulated = accumulated;
|
|
20895
|
+
onStreamChunk(text, accumulated);
|
|
20896
|
+
},
|
|
20897
|
+
signal,
|
|
20898
|
+
logger,
|
|
20899
|
+
tokenizer,
|
|
20900
|
+
metadata: {
|
|
20901
|
+
task: useConventional ? 'commit-message-conventional' : 'commit-message',
|
|
20902
|
+
command: 'commit-draft',
|
|
20903
|
+
provider,
|
|
20904
|
+
model: String(model),
|
|
20905
|
+
},
|
|
20906
|
+
});
|
|
20907
|
+
}
|
|
20908
|
+
catch (streamErr) {
|
|
20909
|
+
// User-initiated cancel (#881 phase 3). Bail out of the
|
|
20910
|
+
// entire attempt loop and let the caller distinguish
|
|
20911
|
+
// "cancelled" from "failed" in the status line. We do NOT
|
|
20912
|
+
// fall through to the non-streaming retry on cancel — the
|
|
20913
|
+
// user explicitly asked to stop, kicking off a fresh
|
|
20914
|
+
// unstreamable LLM call would defy that intent.
|
|
20915
|
+
if (streamErr instanceof LangChainCancelledError) {
|
|
20916
|
+
return {
|
|
20917
|
+
ok: false,
|
|
20918
|
+
draft: streamErr.accumulated || '',
|
|
20919
|
+
warnings,
|
|
20920
|
+
validationErrors: [],
|
|
20921
|
+
cancelled: true,
|
|
20922
|
+
};
|
|
20923
|
+
}
|
|
20924
|
+
// Audit finding #1: try the lossy salvager on the accumulated
|
|
20925
|
+
// text before paying for a second LLM call. The salvager
|
|
20926
|
+
// strips code fences, attempts strict JSON parse, and falls
|
|
20927
|
+
// back to "first line is title, rest is body." We only accept
|
|
20928
|
+
// its output when it produced a real title — the placeholder
|
|
20929
|
+
// title ("Auto-generated commit") means the salvager
|
|
20930
|
+
// couldn't extract anything meaningful and the non-streaming
|
|
20931
|
+
// retry is the better choice.
|
|
20932
|
+
if (streamedAccumulated) {
|
|
20933
|
+
const candidate = salvageCommitMessageFromText(streamedAccumulated);
|
|
20934
|
+
if (candidate.title && candidate.title !== 'Auto-generated commit') {
|
|
20935
|
+
salvaged = candidate;
|
|
20936
|
+
logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
|
|
20374
20937
|
}
|
|
20375
20938
|
}
|
|
20376
|
-
|
|
20377
|
-
|
|
20939
|
+
if (!salvaged) {
|
|
20940
|
+
logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
|
|
20378
20941
|
}
|
|
20379
|
-
|
|
20380
|
-
|
|
20381
|
-
|
|
20382
|
-
|
|
20383
|
-
|
|
20384
|
-
|
|
20942
|
+
}
|
|
20943
|
+
// Type-narrow: commitMsg is set inside try{}, but TS doesn't
|
|
20944
|
+
// see that across the catch. Re-init through the salvage path
|
|
20945
|
+
// if streaming threw.
|
|
20946
|
+
if (salvaged) {
|
|
20947
|
+
commitMsg = salvaged;
|
|
20948
|
+
}
|
|
20949
|
+
else if (!(commitMsg)) {
|
|
20950
|
+
// Streaming threw AND the salvager couldn't recover anything
|
|
20951
|
+
// useful; fall back to the standard non-streaming flow.
|
|
20952
|
+
// Documented trade-off from the issue: streaming gives us a
|
|
20953
|
+
// preview but the validated result still comes from the
|
|
20954
|
+
// schema-aware retry path when both streaming AND salvage
|
|
20955
|
+
// fail.
|
|
20956
|
+
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20957
|
+
logger,
|
|
20958
|
+
tokenizer,
|
|
20959
|
+
metadata: {
|
|
20960
|
+
task: useConventional ? 'commit-message-conventional' : 'commit-message',
|
|
20961
|
+
command: 'commit-draft',
|
|
20962
|
+
provider,
|
|
20963
|
+
model: String(model),
|
|
20964
|
+
},
|
|
20965
|
+
retryOptions: { maxAttempts: maxParsingAttempts },
|
|
20966
|
+
fallbackParser: salvageCommitMessageFromText,
|
|
20967
|
+
});
|
|
20968
|
+
}
|
|
20969
|
+
}
|
|
20970
|
+
else {
|
|
20971
|
+
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20972
|
+
logger,
|
|
20973
|
+
tokenizer,
|
|
20974
|
+
metadata: {
|
|
20975
|
+
task: useConventional ? 'commit-message-conventional' : 'commit-message',
|
|
20976
|
+
command: 'commit-draft',
|
|
20977
|
+
provider,
|
|
20978
|
+
model: String(model),
|
|
20979
|
+
},
|
|
20980
|
+
retryOptions: {
|
|
20981
|
+
maxAttempts: maxParsingAttempts,
|
|
20982
|
+
},
|
|
20983
|
+
fallbackParser: salvageCommitMessageFromText,
|
|
20984
|
+
});
|
|
20985
|
+
}
|
|
20385
20986
|
const ticketId = extractTicketIdFromBranchName(branchName);
|
|
20386
20987
|
const fullMessage = formatCommitMessage(commitMsg, {
|
|
20387
20988
|
append: argv.append,
|
|
@@ -20479,8 +21080,26 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
20479
21080
|
const argv = createCommitWorkflowArgv('commit');
|
|
20480
21081
|
const logger = new Logger({ silent: true });
|
|
20481
21082
|
try {
|
|
20482
|
-
const result = await generateCommitDraft({
|
|
21083
|
+
const result = await generateCommitDraft({
|
|
21084
|
+
git,
|
|
21085
|
+
argv,
|
|
21086
|
+
logger,
|
|
21087
|
+
onStreamChunk: input.onStreamChunk,
|
|
21088
|
+
signal: input.signal,
|
|
21089
|
+
});
|
|
20483
21090
|
const draft = result.draft.trim();
|
|
21091
|
+
// Cancel path (#881 phase 3). Reported separately from success
|
|
21092
|
+
// / failure so the runtime can render a neutral "cancelled"
|
|
21093
|
+
// status line instead of an error.
|
|
21094
|
+
if (result.cancelled) {
|
|
21095
|
+
return {
|
|
21096
|
+
ok: false,
|
|
21097
|
+
message: 'AI draft cancelled.',
|
|
21098
|
+
details: [],
|
|
21099
|
+
draft: '',
|
|
21100
|
+
cancelled: true,
|
|
21101
|
+
};
|
|
21102
|
+
}
|
|
20484
21103
|
if (result.ok && draft) {
|
|
20485
21104
|
return {
|
|
20486
21105
|
ok: true,
|
|
@@ -20569,6 +21188,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
20569
21188
|
ok: true,
|
|
20570
21189
|
plan: result.plan,
|
|
20571
21190
|
planContext: result.context,
|
|
21191
|
+
fallback: result.fallback,
|
|
20572
21192
|
};
|
|
20573
21193
|
}
|
|
20574
21194
|
catch (error) {
|
|
@@ -20613,6 +21233,7 @@ async function runCommitSplitApplyWorkflow(input) {
|
|
|
20613
21233
|
git,
|
|
20614
21234
|
logger,
|
|
20615
21235
|
noVerify: input.noVerify || false,
|
|
21236
|
+
fallback: input.fallback,
|
|
20616
21237
|
});
|
|
20617
21238
|
return {
|
|
20618
21239
|
ok: true,
|
|
@@ -20623,6 +21244,7 @@ async function runCommitSplitApplyWorkflow(input) {
|
|
|
20623
21244
|
// I/O AND inaccurate when partial-apply landed fewer commits
|
|
20624
21245
|
// than the plan had groups.
|
|
20625
21246
|
commitHashes: applied.commitHashes,
|
|
21247
|
+
fallback: applied.fallback,
|
|
20626
21248
|
};
|
|
20627
21249
|
}
|
|
20628
21250
|
catch (error) {
|
|
@@ -22767,10 +23389,17 @@ function withPoppedView(state) {
|
|
|
22767
23389
|
* in a clean slate — the mental equivalent of a fresh `coco ui`
|
|
22768
23390
|
* launched against the submodule's working dir.
|
|
22769
23391
|
*
|
|
22770
|
-
*
|
|
22771
|
-
*
|
|
22772
|
-
*
|
|
22773
|
-
*
|
|
23392
|
+
* Sidebar tab + branch / tag sort are also captured into the return
|
|
23393
|
+
* snapshot (#995) so popping back restores the parent's choices
|
|
23394
|
+
* instead of letting the submodule's tab/sort bleed across the
|
|
23395
|
+
* boundary. The values on the *new* frame are left as-is (carried
|
|
23396
|
+
* over from the parent) — the load effect in app.ts re-reads
|
|
23397
|
+
* persistence keyed on the submodule's workdir and dispatches a
|
|
23398
|
+
* restore if the user has a submodule-specific saved preference.
|
|
23399
|
+
*
|
|
23400
|
+
* Other preferences (palette recents, inspector tab, diff view mode)
|
|
23401
|
+
* stay global by design — the user's preference shouldn't reset when
|
|
23402
|
+
* they cross a submodule boundary.
|
|
22774
23403
|
*
|
|
22775
23404
|
* Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
|
|
22776
23405
|
* outside the reducer in `app.ts`'s parallel ref structure — this
|
|
@@ -22787,6 +23416,10 @@ function withPushedRepoFrame(state, payload) {
|
|
|
22787
23416
|
selectedFileIndex: state.selectedFileIndex,
|
|
22788
23417
|
selectedSubmoduleIndex: state.selectedSubmoduleIndex,
|
|
22789
23418
|
filter: state.filter,
|
|
23419
|
+
sidebarTab: state.sidebarTab,
|
|
23420
|
+
userSidebarTab: state.userSidebarTab,
|
|
23421
|
+
branchSort: state.branchSort,
|
|
23422
|
+
tagSort: state.tagSort,
|
|
22790
23423
|
},
|
|
22791
23424
|
};
|
|
22792
23425
|
return {
|
|
@@ -22839,6 +23472,15 @@ function withPoppedRepoFrame(state) {
|
|
|
22839
23472
|
filter: ret.filter,
|
|
22840
23473
|
filterMode: false,
|
|
22841
23474
|
pendingCommitFocused: false,
|
|
23475
|
+
// #995 — restore sidebar tab + sort preferences from the captured
|
|
23476
|
+
// parentReturn. Without this, the submodule's tab / sort choice
|
|
23477
|
+
// bleeds back into the parent after pop: the user picks 'tags' in
|
|
23478
|
+
// a vendored submodule, pops back to the parent, and finds the
|
|
23479
|
+
// parent's previously-selected 'branches' tab quietly replaced.
|
|
23480
|
+
sidebarTab: ret.sidebarTab,
|
|
23481
|
+
userSidebarTab: ret.userSidebarTab,
|
|
23482
|
+
branchSort: ret.branchSort,
|
|
23483
|
+
tagSort: ret.tagSort,
|
|
22842
23484
|
pendingKey: undefined,
|
|
22843
23485
|
pendingConfirmationId: undefined,
|
|
22844
23486
|
pendingConfirmationPayload: undefined,
|
|
@@ -23614,6 +24256,17 @@ function applyLogInkAction(state, action) {
|
|
|
23614
24256
|
statusLoading: !action.value ? undefined : (action.loading ? true : undefined),
|
|
23615
24257
|
pendingKey: undefined,
|
|
23616
24258
|
};
|
|
24259
|
+
case 'setPendingPullRequestBodyDraft':
|
|
24260
|
+
// PR-body draft tracker (#881 phase 4). Set true while
|
|
24261
|
+
// `startCreatePullRequest` is awaiting the changelog-based
|
|
24262
|
+
// body generation; gates the Esc cancel binding in the input
|
|
24263
|
+
// handler so pressing Esc during the wait skips opening the
|
|
24264
|
+
// follow-up prompt instead of falling through to global Esc.
|
|
24265
|
+
return {
|
|
24266
|
+
...state,
|
|
24267
|
+
pendingPullRequestBodyDraft: action.value || undefined,
|
|
24268
|
+
pendingKey: undefined,
|
|
24269
|
+
};
|
|
23617
24270
|
case 'setWorkflowAction':
|
|
23618
24271
|
return {
|
|
23619
24272
|
...state,
|
|
@@ -23742,10 +24395,14 @@ function applyLogInkAction(state, action) {
|
|
|
23742
24395
|
// Cache the result so re-entry (or `c` to PR) reuses it instead of
|
|
23743
24396
|
// re-running the LLM. Keyed by branch so a checkout naturally
|
|
23744
24397
|
// produces a fresh generation.
|
|
24398
|
+
// Audit finding #9: `generatedAt` arrives on the action payload
|
|
24399
|
+
// instead of being read from `Date.now()` here, so the reducer
|
|
24400
|
+
// stays pure. Dispatchers (currently `runChangelogView` in
|
|
24401
|
+
// app.ts) call `Date.now()` at dispatch time.
|
|
23745
24402
|
const cached = {
|
|
23746
24403
|
text: action.text,
|
|
23747
24404
|
baseLabel: action.baseLabel,
|
|
23748
|
-
generatedAt:
|
|
24405
|
+
generatedAt: action.generatedAt,
|
|
23749
24406
|
};
|
|
23750
24407
|
return {
|
|
23751
24408
|
...state,
|
|
@@ -23799,7 +24456,8 @@ function applyLogInkAction(state, action) {
|
|
|
23799
24456
|
// Updated-at timestamp reflects the edit. Not the original
|
|
23800
24457
|
// generation time — `r` (regenerate) is the explicit knob
|
|
23801
24458
|
// for "I want fresh LLM output, not my edits".
|
|
23802
|
-
|
|
24459
|
+
// Audit finding #9: timestamp arrives on the action.
|
|
24460
|
+
generatedAt: action.generatedAt,
|
|
23803
24461
|
},
|
|
23804
24462
|
},
|
|
23805
24463
|
pendingKey: undefined,
|
|
@@ -23835,7 +24493,9 @@ function applyLogInkAction(state, action) {
|
|
|
23835
24493
|
}
|
|
23836
24494
|
return {
|
|
23837
24495
|
...state,
|
|
23838
|
-
|
|
24496
|
+
// Audit finding #9: timestamp arrives on the action payload
|
|
24497
|
+
// instead of being read from `Date.now()` here.
|
|
24498
|
+
recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
|
|
23839
24499
|
pendingKey: undefined,
|
|
23840
24500
|
};
|
|
23841
24501
|
case 'clearRecentCommits':
|
|
@@ -23857,6 +24517,7 @@ function applyLogInkAction(state, action) {
|
|
|
23857
24517
|
plan: action.plan,
|
|
23858
24518
|
planContext: action.planContext,
|
|
23859
24519
|
scrollOffset: 0,
|
|
24520
|
+
fallback: action.fallback,
|
|
23860
24521
|
},
|
|
23861
24522
|
pendingKey: undefined,
|
|
23862
24523
|
};
|
|
@@ -24633,6 +25294,65 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24633
25294
|
}
|
|
24634
25295
|
return [];
|
|
24635
25296
|
}
|
|
25297
|
+
// Cancel in-flight AI commit draft (#881 phase 3). When the compose
|
|
25298
|
+
// state has a draft in flight (loading === true), Esc aborts the
|
|
25299
|
+
// LLM call and the runtime handler cleans up (clear loading, clear
|
|
25300
|
+
// preview, status line shows "AI draft cancelled.").
|
|
25301
|
+
//
|
|
25302
|
+
// Audit finding #5: the `activeView === 'compose'` gate from the
|
|
25303
|
+
// original phase 3 implementation made the cancel keystroke
|
|
25304
|
+
// unreachable after the user chord-navigated away from compose
|
|
25305
|
+
// mid-stream (Esc would fall through to popView etc., consuming
|
|
25306
|
+
// the navigation intent while the LLM call silently ran to
|
|
25307
|
+
// completion). Cancel should work wherever the user is — they
|
|
25308
|
+
// can always navigate back to compose afterwards.
|
|
25309
|
+
//
|
|
25310
|
+
// Sits above the editing / view handlers so the cancel keystroke
|
|
25311
|
+
// can't fall through to "leave compose" or anything else. Loading
|
|
25312
|
+
// and editing are mutually exclusive in practice (the user can't
|
|
25313
|
+
// type while the AI is generating), but the order here makes the
|
|
25314
|
+
// precedence explicit if that ever changes.
|
|
25315
|
+
if (state.commitCompose.loading && key.escape) {
|
|
25316
|
+
return [{ type: 'cancelAiCommitDraft' }];
|
|
25317
|
+
}
|
|
25318
|
+
// Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
|
|
25319
|
+
// kicks off a changelog-based draft that runs for 5-15 seconds
|
|
25320
|
+
// before the input prompt opens. While the draft is pending, Esc
|
|
25321
|
+
// tells the runtime to skip the prompt and surface a "cancelled"
|
|
25322
|
+
// status. Unlike the compose cancel above, this is a *soft* cancel
|
|
25323
|
+
// — the background LLM call still completes, but its result is
|
|
25324
|
+
// discarded. Acceptable trade-off for now; deeper signal threading
|
|
25325
|
+
// through `changelogHandler` lands in a follow-up if real cancel
|
|
25326
|
+
// becomes a request.
|
|
25327
|
+
//
|
|
25328
|
+
// Sits unconditionally on the global Esc check (no `activeView`
|
|
25329
|
+
// gate) because the draft can be initiated from any view via the
|
|
25330
|
+
// palette `C` binding; Esc must work wherever the user is when
|
|
25331
|
+
// they decide to bail.
|
|
25332
|
+
if (state.pendingPullRequestBodyDraft && key.escape) {
|
|
25333
|
+
return [{ type: 'cancelPullRequestBodyDraft' }];
|
|
25334
|
+
}
|
|
25335
|
+
// Pending AI draft confirmation (audit finding #7). When the AI
|
|
25336
|
+
// draft completes against a non-empty compose surface, it lands in
|
|
25337
|
+
// `pendingAiDraft` instead of overwriting the user's typing. `R`
|
|
25338
|
+
// accepts the swap (user's typing is lost, AI draft becomes the
|
|
25339
|
+
// new content). `Esc` dismisses the AI draft (typing is preserved,
|
|
25340
|
+
// AI draft is lost — the user paid for the tokens but explicitly
|
|
25341
|
+
// chose not to use them).
|
|
25342
|
+
//
|
|
25343
|
+
// Gated on `activeView === 'compose'` because the pending draft is
|
|
25344
|
+
// only meaningful on the compose surface (where the message line
|
|
25345
|
+
// surfaces the prompt). A user who chord-navigated away while the
|
|
25346
|
+
// draft was pending should see the original `R` / Esc semantics of
|
|
25347
|
+
// wherever they are now.
|
|
25348
|
+
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
25349
|
+
if (inputValue === 'R' && !key.ctrl && !key.meta) {
|
|
25350
|
+
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
25351
|
+
}
|
|
25352
|
+
if (key.escape) {
|
|
25353
|
+
return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
|
|
25354
|
+
}
|
|
25355
|
+
}
|
|
24636
25356
|
if (state.commitCompose.editing) {
|
|
24637
25357
|
if (key.escape) {
|
|
24638
25358
|
return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
|
|
@@ -26649,17 +27369,24 @@ function formatRemainingWorktreeHint(unstaged, untracked) {
|
|
|
26649
27369
|
*
|
|
26650
27370
|
* When the worktree is clean post-apply:
|
|
26651
27371
|
* "Created N commits — press gh to view them in history. Worktree is clean."
|
|
27372
|
+
*
|
|
27373
|
+
* When `fallback` is set, the planner exhausted its retry budget and
|
|
27374
|
+
* the apply landed the single-group fallback plan instead of a real
|
|
27375
|
+
* multi-group split. Prefix the message so the user knows the result
|
|
27376
|
+
* isn't a true LLM split — they may want to re-roll with a different
|
|
27377
|
+
* model, or accept the combined commit as-is.
|
|
26652
27378
|
*/
|
|
26653
|
-
function formatSplitApplySuccess(commitCount, unstaged, untracked) {
|
|
27379
|
+
function formatSplitApplySuccess(commitCount, unstaged, untracked, fallback) {
|
|
26654
27380
|
const created = commitCount === 1
|
|
26655
27381
|
? 'Created 1 commit'
|
|
26656
27382
|
: `Created ${commitCount} commits`;
|
|
26657
27383
|
const navCue = `${created} — press gh to view them in history.`;
|
|
26658
27384
|
const remainingHint = formatRemainingWorktreeHint(unstaged, untracked);
|
|
26659
|
-
|
|
26660
|
-
|
|
27385
|
+
const tail = remainingHint ? ` ${remainingHint}` : ' Worktree is clean.';
|
|
27386
|
+
if (fallback) {
|
|
27387
|
+
return `Split planner fallback applied (combined commit) — ${fallback.reason}. ${navCue}${tail}`;
|
|
26661
27388
|
}
|
|
26662
|
-
return `${navCue}
|
|
27389
|
+
return `${navCue}${tail}`;
|
|
26663
27390
|
}
|
|
26664
27391
|
|
|
26665
27392
|
/**
|
|
@@ -30557,6 +31284,97 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
|
|
|
30557
31284
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
|
|
30558
31285
|
}
|
|
30559
31286
|
|
|
31287
|
+
/**
|
|
31288
|
+
* Streaming-preview helper (#881 phase 2). Turns the raw accumulated
|
|
31289
|
+
* text from an in-flight LLM stream into the last N visual lines that
|
|
31290
|
+
* fit a given panel width, plus a flag telling the renderer whether
|
|
31291
|
+
* earlier content was elided.
|
|
31292
|
+
*
|
|
31293
|
+
* Why a chrome helper instead of inlining the math in the compose
|
|
31294
|
+
* surface: the same shape is going to be reused by PR-body and review
|
|
31295
|
+
* streaming once those surfaces opt in. The visual line math (wrap to
|
|
31296
|
+
* width, count from the bottom, mark truncation) doesn't belong on the
|
|
31297
|
+
* surface itself.
|
|
31298
|
+
*
|
|
31299
|
+
* No JSX / no Ink here — chrome modules stay framework-agnostic and
|
|
31300
|
+
* return data the surface can hand to its own `h(Text, ...)` calls.
|
|
31301
|
+
*/
|
|
31302
|
+
/**
|
|
31303
|
+
* Default last-N visible visual lines. Tuned for compose where the
|
|
31304
|
+
* panel already shows summary + body + loading line, so the preview
|
|
31305
|
+
* can't take more vertical space without pushing the state-line off
|
|
31306
|
+
* the bottom of short terminals. 6 lines is roughly two short
|
|
31307
|
+
* commit-body paragraphs — enough to feel like content is flowing,
|
|
31308
|
+
* not so much that the user loses sight of the surrounding chrome.
|
|
31309
|
+
*/
|
|
31310
|
+
const DEFAULT_STREAMING_PREVIEW_LINES = 6;
|
|
31311
|
+
/**
|
|
31312
|
+
* Marker prefixed to the first visible line when earlier content was
|
|
31313
|
+
* elided. Chrome theme picks ASCII vs Unicode at render time; this
|
|
31314
|
+
* module returns both so surfaces don't need to import the theme.
|
|
31315
|
+
*/
|
|
31316
|
+
const STREAMING_PREVIEW_TRUNCATE_GLYPH = '…';
|
|
31317
|
+
const STREAMING_PREVIEW_TRUNCATE_ASCII = '...';
|
|
31318
|
+
/**
|
|
31319
|
+
* Compute the visible preview window for a streaming buffer.
|
|
31320
|
+
*
|
|
31321
|
+
* The buffer is split on newlines (preserving blank lines so paragraph
|
|
31322
|
+
* spacing stays visible), each source line is hard-wrapped to `width`,
|
|
31323
|
+
* and the trailing `maxLines` wrapped lines are returned. When the
|
|
31324
|
+
* total wrapped line count exceeds `maxLines`, `truncated` is true so
|
|
31325
|
+
* the renderer can prefix the first line with an ellipsis marker.
|
|
31326
|
+
*
|
|
31327
|
+
* Whitespace-only / empty input returns `{ lines: [], truncated: false }`
|
|
31328
|
+
* so renderers can branch on `lines.length === 0` to skip rendering
|
|
31329
|
+
* entirely during the brief window between dispatching `setLoading`
|
|
31330
|
+
* and the first chunk arriving.
|
|
31331
|
+
*
|
|
31332
|
+
* Width math mirrors the compose surface's body wrap (`width - 6` for
|
|
31333
|
+
* border + paddingX + 2-space indent budget); callers pass the width
|
|
31334
|
+
* they intend to use and this helper assumes it's the wrap budget,
|
|
31335
|
+
* not the panel width.
|
|
31336
|
+
*/
|
|
31337
|
+
function formatStreamingPreview(accumulated, width, maxLines = DEFAULT_STREAMING_PREVIEW_LINES) {
|
|
31338
|
+
if (!accumulated) {
|
|
31339
|
+
return { lines: [], truncated: false };
|
|
31340
|
+
}
|
|
31341
|
+
const trimmed = accumulated.replace(/\s+$/u, '');
|
|
31342
|
+
if (!trimmed) {
|
|
31343
|
+
return { lines: [], truncated: false };
|
|
31344
|
+
}
|
|
31345
|
+
// Wrap each source line. Empty source lines must survive the wrap so
|
|
31346
|
+
// a stream like "A\n\nB" reads as two paragraphs separated by a blank
|
|
31347
|
+
// row rather than collapsing into "A B".
|
|
31348
|
+
const wrapWidth = Math.max(8, width);
|
|
31349
|
+
const wrapped = [];
|
|
31350
|
+
for (const line of trimmed.split('\n')) {
|
|
31351
|
+
if (line === '') {
|
|
31352
|
+
wrapped.push('');
|
|
31353
|
+
continue;
|
|
31354
|
+
}
|
|
31355
|
+
for (const segment of wrapCells(line, wrapWidth)) {
|
|
31356
|
+
wrapped.push(segment);
|
|
31357
|
+
}
|
|
31358
|
+
}
|
|
31359
|
+
const budget = Math.max(1, maxLines);
|
|
31360
|
+
if (wrapped.length <= budget) {
|
|
31361
|
+
return { lines: wrapped, truncated: false };
|
|
31362
|
+
}
|
|
31363
|
+
return {
|
|
31364
|
+
lines: wrapped.slice(wrapped.length - budget),
|
|
31365
|
+
truncated: true,
|
|
31366
|
+
};
|
|
31367
|
+
}
|
|
31368
|
+
/**
|
|
31369
|
+
* Resolve the truncation marker for the current theme. Pure helper so
|
|
31370
|
+
* the surface can render a single-character glyph in colour terminals
|
|
31371
|
+
* and the ASCII fallback when `theme.ascii` is on. Centralised here so
|
|
31372
|
+
* future surfaces opting into streaming use the same glyph.
|
|
31373
|
+
*/
|
|
31374
|
+
function streamingPreviewTruncateMarker(ascii) {
|
|
31375
|
+
return ascii ? STREAMING_PREVIEW_TRUNCATE_ASCII : STREAMING_PREVIEW_TRUNCATE_GLYPH;
|
|
31376
|
+
}
|
|
31377
|
+
|
|
30560
31378
|
/**
|
|
30561
31379
|
* Compose surface — the in-TUI commit-message composer. Combines a
|
|
30562
31380
|
* summary line, a body field, and a state-line footer; an inline
|
|
@@ -30566,6 +31384,33 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
|
|
|
30566
31384
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
30567
31385
|
* of #890. No behavior change.
|
|
30568
31386
|
*/
|
|
31387
|
+
/**
|
|
31388
|
+
* Render the streaming-preview block — the trailing lines of the
|
|
31389
|
+
* in-flight LLM stream that sit below the loading spinner. Pure
|
|
31390
|
+
* formatting; the wrap math + truncation flag live in the
|
|
31391
|
+
* `streamingPreview` chrome helper so other surfaces (PR body,
|
|
31392
|
+
* review) can reuse them later.
|
|
31393
|
+
*
|
|
31394
|
+
* Returns an empty array when no preview text is present (the loader
|
|
31395
|
+
* just shows the spinner) so the caller's spread doesn't insert blank
|
|
31396
|
+
* rows that would shift the state-line.
|
|
31397
|
+
*/
|
|
31398
|
+
function renderStreamingPreviewLines(h, components, preview, width, theme) {
|
|
31399
|
+
const { Text } = components;
|
|
31400
|
+
const view = formatStreamingPreview(preview, width);
|
|
31401
|
+
if (view.lines.length === 0)
|
|
31402
|
+
return [];
|
|
31403
|
+
const marker = view.truncated ? streamingPreviewTruncateMarker(theme.ascii) : '';
|
|
31404
|
+
return view.lines.map((line, index) => {
|
|
31405
|
+
// Prefix the first line with the truncation marker when earlier
|
|
31406
|
+
// content was elided. Subsequent lines render unprefixed.
|
|
31407
|
+
const prefix = index === 0 && marker ? `${marker} ` : ' ';
|
|
31408
|
+
return h(Text, {
|
|
31409
|
+
key: `compose-stream-${index}`,
|
|
31410
|
+
dimColor: true,
|
|
31411
|
+
}, `${prefix}${line}`);
|
|
31412
|
+
});
|
|
31413
|
+
}
|
|
30569
31414
|
function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
|
|
30570
31415
|
const { Box, Text } = components;
|
|
30571
31416
|
const compose = state.commitCompose;
|
|
@@ -30589,9 +31434,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
30589
31434
|
: ['<empty>'];
|
|
30590
31435
|
const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
|
|
30591
31436
|
);
|
|
31437
|
+
// State-line cycles through three modes (#881 phase 3 added the
|
|
31438
|
+
// loading variant): editing copy when the user is typing, cancel
|
|
31439
|
+
// hint when an AI draft is generating, default guidance otherwise.
|
|
31440
|
+
// The cancel hint also covers the streaming preview window — same
|
|
31441
|
+
// keystroke (Esc) aborts whether or not the preview is visible.
|
|
30592
31442
|
const stateLine = compose.editing
|
|
30593
31443
|
? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
|
|
30594
|
-
:
|
|
31444
|
+
: compose.loading
|
|
31445
|
+
? 'Generating AI draft — press Esc to cancel.'
|
|
31446
|
+
: 'Press e to edit, c to commit, I for AI draft, esc to leave.';
|
|
30595
31447
|
const hasStagedFiles = (worktree?.files || [])
|
|
30596
31448
|
.some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
|
|
30597
31449
|
// Staged file list is rendered in the right Worktree panel
|
|
@@ -30638,6 +31490,13 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
30638
31490
|
}, theme.ascii
|
|
30639
31491
|
? `[${pickSpinnerFrame(spinnerFrame).replace(/[^a-zA-Z0-9 ]/g, '.')}] Generating AI commit draft (this can take a moment)`
|
|
30640
31492
|
: `${pickSpinnerFrame(spinnerFrame)} Generating AI commit draft… (this can take a moment)`),
|
|
31493
|
+
// Streaming preview (#881 phase 2). Renders the trailing visual
|
|
31494
|
+
// lines of the in-flight LLM stream below the loader so the user
|
|
31495
|
+
// sees content building up instead of an opaque spinner. Empty
|
|
31496
|
+
// before the first chunk arrives; the preview helper returns an
|
|
31497
|
+
// empty `lines` array in that window so we skip the block
|
|
31498
|
+
// entirely.
|
|
31499
|
+
...renderStreamingPreviewLines(h, components, compose.streamingPreview, bodyTextWidth, theme),
|
|
30641
31500
|
]
|
|
30642
31501
|
: []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncateCells(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
|
|
30643
31502
|
key: `compose-detail-${index}`,
|
|
@@ -35511,9 +36370,18 @@ function LogInkApp(deps) {
|
|
|
35511
36370
|
// Wrappers that delegate to the active frame's runtime entry so the
|
|
35512
36371
|
// existing call sites stay byte-identical. Support both function-
|
|
35513
36372
|
// updater and value-updater forms (the codebase uses both).
|
|
35514
|
-
|
|
36373
|
+
//
|
|
36374
|
+
// `targetDepth` (#994) routes the write to a specific frame instead
|
|
36375
|
+
// of the currently-active one. Loaders that capture the depth at
|
|
36376
|
+
// issue-time and pass it here are robust against frame-stack
|
|
36377
|
+
// mutations (push / pop) that happen while the load is in flight —
|
|
36378
|
+
// the write lands on the frame that issued it, or silently drops
|
|
36379
|
+
// if that frame has been popped (`updateRepoFrameRuntime` no-ops on
|
|
36380
|
+
// out-of-range indices). Without the tag, an in-flight refresh on
|
|
36381
|
+
// the parent would clobber a freshly-pushed submodule frame.
|
|
36382
|
+
const setContext = React.useCallback((arg, targetDepth) => {
|
|
35515
36383
|
setRuntimes((prev) => {
|
|
35516
|
-
const depth = prev.length - 1;
|
|
36384
|
+
const depth = targetDepth ?? prev.length - 1;
|
|
35517
36385
|
if (depth < 0)
|
|
35518
36386
|
return prev;
|
|
35519
36387
|
return updateRepoFrameRuntime(prev, depth, (frame) => ({
|
|
@@ -35524,9 +36392,9 @@ function LogInkApp(deps) {
|
|
|
35524
36392
|
}));
|
|
35525
36393
|
});
|
|
35526
36394
|
}, []);
|
|
35527
|
-
const setContextStatus = React.useCallback((arg) => {
|
|
36395
|
+
const setContextStatus = React.useCallback((arg, targetDepth) => {
|
|
35528
36396
|
setRuntimes((prev) => {
|
|
35529
|
-
const depth = prev.length - 1;
|
|
36397
|
+
const depth = targetDepth ?? prev.length - 1;
|
|
35530
36398
|
if (depth < 0)
|
|
35531
36399
|
return prev;
|
|
35532
36400
|
return updateRepoFrameRuntime(prev, depth, (frame) => ({
|
|
@@ -35543,6 +36411,14 @@ function LogInkApp(deps) {
|
|
|
35543
36411
|
// workdirs for submodule paths recorded in `.gitmodules` (which
|
|
35544
36412
|
// are repo-relative). Undefined during the brief moment between
|
|
35545
36413
|
// git swap and the revparse callback resolving.
|
|
36414
|
+
//
|
|
36415
|
+
// Audit finding #10: rapid frame push/pop races are prevented by
|
|
36416
|
+
// the per-effect `cancelled` flag — React fires the cleanup
|
|
36417
|
+
// synchronously BEFORE running the next effect body, so any
|
|
36418
|
+
// pending revparse from the old `git` sees `cancelled === true`
|
|
36419
|
+
// and skips its write. The `git` reference itself is captured by
|
|
36420
|
+
// closure, so each effect run resolves against the right binding.
|
|
36421
|
+
// No additional depth tagging is needed.
|
|
35546
36422
|
const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
|
|
35547
36423
|
React.useEffect(() => {
|
|
35548
36424
|
let cancelled = false;
|
|
@@ -35852,28 +36728,39 @@ function LogInkApp(deps) {
|
|
|
35852
36728
|
// (stale-while-revalidate) and quietly swap it in once the new fetch
|
|
35853
36729
|
// resolves — avoids the every-second flicker the watcher would
|
|
35854
36730
|
// otherwise produce on busy repos.
|
|
36731
|
+
//
|
|
36732
|
+
// #994 — capture the depth this refresh was issued from BEFORE
|
|
36733
|
+
// the await. The callback closure also captured `git` from the
|
|
36734
|
+
// same render, so they're consistent: when the user drills into
|
|
36735
|
+
// a submodule mid-await, the resolved data still lands on the
|
|
36736
|
+
// parent frame (the one whose `git` was used for the fetch),
|
|
36737
|
+
// not on the freshly-pushed submodule frame.
|
|
36738
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35855
36739
|
if (!options.silent) {
|
|
35856
36740
|
dispatch({ type: 'setStatus', value: 'refreshing repository context' });
|
|
35857
|
-
setContextStatus(createLogInkContextStatus('loading'));
|
|
36741
|
+
setContextStatus(createLogInkContextStatus('loading'), issuedAtDepth);
|
|
35858
36742
|
}
|
|
35859
36743
|
const next = await loadLogInkContext(git);
|
|
35860
|
-
setContext(next);
|
|
35861
|
-
setContextStatus(createLogInkContextStatus('ready'));
|
|
36744
|
+
setContext(next, issuedAtDepth);
|
|
36745
|
+
setContextStatus(createLogInkContextStatus('ready'), issuedAtDepth);
|
|
35862
36746
|
if (!options.silent) {
|
|
35863
36747
|
dispatch({ type: 'setStatus', value: 'repository context refreshed' });
|
|
35864
36748
|
}
|
|
35865
|
-
}, [dispatch, git]);
|
|
36749
|
+
}, [dispatch, git, runtimes.length, setContext, setContextStatus]);
|
|
35866
36750
|
const refreshWorktreeContext = React.useCallback(async (options = {}) => {
|
|
36751
|
+
// #994 — same frame-tagging as refreshContext above. Worktree
|
|
36752
|
+
// loads are usually fast but still race-prone on slow disks.
|
|
36753
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35867
36754
|
if (!options.silent) {
|
|
35868
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
|
|
36755
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'), issuedAtDepth);
|
|
35869
36756
|
}
|
|
35870
36757
|
const worktree = await safe(getWorktreeOverview(git));
|
|
35871
36758
|
setContext((current) => ({
|
|
35872
36759
|
...current,
|
|
35873
36760
|
worktree,
|
|
35874
|
-
}));
|
|
35875
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
|
|
35876
|
-
}, [git]);
|
|
36761
|
+
}), issuedAtDepth);
|
|
36762
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
|
|
36763
|
+
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
35877
36764
|
// Live refresh: watch .git metadata + the working tree root and reload
|
|
35878
36765
|
// context when something changes outside the TUI (editor save, external
|
|
35879
36766
|
// git commands, branch switch in another terminal). Best-effort — the
|
|
@@ -35962,18 +36849,50 @@ function LogInkApp(deps) {
|
|
|
35962
36849
|
})();
|
|
35963
36850
|
return () => { cancelled = true; };
|
|
35964
36851
|
}, [git, dispatch]);
|
|
36852
|
+
// Audit finding #2: re-resolve the repo root inline on every save
|
|
36853
|
+
// and key the deps off `git` + the saved value. The original
|
|
36854
|
+
// implementation read from `repoRootRef.current`, which is async-
|
|
36855
|
+
// populated by the resolver effect above and can lag behind a git
|
|
36856
|
+
// swap. After #995's synchronous pop-restore, the parent's freshly
|
|
36857
|
+
// restored sidebar tab was being written into the submodule's
|
|
36858
|
+
// cache because the ref still held the submodule root during the
|
|
36859
|
+
// brief window before the resolver settled.
|
|
36860
|
+
//
|
|
36861
|
+
// The extra `revparse` cost per save is negligible (saves fire
|
|
36862
|
+
// once per user-initiated tab change, not per render) and the
|
|
36863
|
+
// cancellation flag prevents a stale resolution from racing a
|
|
36864
|
+
// newer one in flight.
|
|
35965
36865
|
React.useEffect(() => {
|
|
35966
|
-
|
|
35967
|
-
|
|
35968
|
-
|
|
35969
|
-
|
|
35970
|
-
|
|
36866
|
+
let cancelled = false;
|
|
36867
|
+
void (async () => {
|
|
36868
|
+
try {
|
|
36869
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
36870
|
+
if (cancelled || !root)
|
|
36871
|
+
return;
|
|
36872
|
+
saveSidebarTab(root, state.userSidebarTab);
|
|
36873
|
+
}
|
|
36874
|
+
catch {
|
|
36875
|
+
// Not in a worktree, or revparse failed — silently skip.
|
|
36876
|
+
// The next save attempt will retry.
|
|
36877
|
+
}
|
|
36878
|
+
})();
|
|
36879
|
+
return () => { cancelled = true; };
|
|
36880
|
+
}, [state.userSidebarTab, git]);
|
|
35971
36881
|
React.useEffect(() => {
|
|
35972
|
-
|
|
35973
|
-
|
|
35974
|
-
|
|
35975
|
-
|
|
35976
|
-
|
|
36882
|
+
let cancelled = false;
|
|
36883
|
+
void (async () => {
|
|
36884
|
+
try {
|
|
36885
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
36886
|
+
if (cancelled || !root)
|
|
36887
|
+
return;
|
|
36888
|
+
saveDiffViewMode(root, state.diffViewMode);
|
|
36889
|
+
}
|
|
36890
|
+
catch {
|
|
36891
|
+
// Same as above.
|
|
36892
|
+
}
|
|
36893
|
+
})();
|
|
36894
|
+
return () => { cancelled = true; };
|
|
36895
|
+
}, [state.diffViewMode, git]);
|
|
35977
36896
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
35978
36897
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
35979
36898
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -36098,6 +37017,11 @@ function LogInkApp(deps) {
|
|
|
36098
37017
|
const contextStatusRef = React.useRef(contextStatus);
|
|
36099
37018
|
contextStatusRef.current = contextStatus;
|
|
36100
37019
|
React.useEffect(() => {
|
|
37020
|
+
// #994 — capture the depth this boot load is being issued for.
|
|
37021
|
+
// The git instance in the closure is bound to this frame; tagged
|
|
37022
|
+
// writes ensure resolved values land on the correct runtime entry
|
|
37023
|
+
// even if a subsequent push/pop changes the active frame mid-load.
|
|
37024
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36101
37025
|
let active = true;
|
|
36102
37026
|
loadLogInkContextEntries(git).forEach(({ key, load }) => {
|
|
36103
37027
|
if (contextStatusRef.current[key] === 'ready')
|
|
@@ -36109,14 +37033,14 @@ function LogInkApp(deps) {
|
|
|
36109
37033
|
setContext((current) => ({
|
|
36110
37034
|
...current,
|
|
36111
37035
|
[key]: value,
|
|
36112
|
-
}));
|
|
36113
|
-
setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'));
|
|
37036
|
+
}), issuedAtDepth);
|
|
37037
|
+
setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'), issuedAtDepth);
|
|
36114
37038
|
});
|
|
36115
37039
|
});
|
|
36116
37040
|
return () => {
|
|
36117
37041
|
active = false;
|
|
36118
37042
|
};
|
|
36119
|
-
}, [git]);
|
|
37043
|
+
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
36120
37044
|
// Lazy-load the full pullRequest overview (#808). Only fires when
|
|
36121
37045
|
// the user actually navigates to the PR view, and only when we
|
|
36122
37046
|
// don't already have data (so a workflow-triggered refresh that
|
|
@@ -36130,21 +37054,22 @@ function LogInkApp(deps) {
|
|
|
36130
37054
|
return;
|
|
36131
37055
|
if (context.pullRequest)
|
|
36132
37056
|
return;
|
|
37057
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36133
37058
|
let active = true;
|
|
36134
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
|
|
37059
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'), issuedAtDepth);
|
|
36135
37060
|
void safe(getPullRequestOverview(git)).then((value) => {
|
|
36136
37061
|
if (!active)
|
|
36137
37062
|
return;
|
|
36138
37063
|
setContext((current) => ({
|
|
36139
37064
|
...current,
|
|
36140
37065
|
pullRequest: value,
|
|
36141
|
-
}));
|
|
36142
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
|
|
37066
|
+
}), issuedAtDepth);
|
|
37067
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'), issuedAtDepth);
|
|
36143
37068
|
});
|
|
36144
37069
|
return () => {
|
|
36145
37070
|
active = false;
|
|
36146
37071
|
};
|
|
36147
|
-
}, [git, state.activeView, context.pullRequest]);
|
|
37072
|
+
}, [git, runtimes.length, state.activeView, context.pullRequest, setContext, setContextStatus]);
|
|
36148
37073
|
// Lazy-load the issue triage list (#882 phase 3, filter-aware
|
|
36149
37074
|
// since phase 6). Fires on entry to the view AND on filter
|
|
36150
37075
|
// preset changes (`f` cycles the preset; the dep on
|
|
@@ -36156,8 +37081,9 @@ function LogInkApp(deps) {
|
|
|
36156
37081
|
return;
|
|
36157
37082
|
if (context.issueList)
|
|
36158
37083
|
return;
|
|
37084
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36159
37085
|
let active = true;
|
|
36160
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
|
|
37086
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'), issuedAtDepth);
|
|
36161
37087
|
const filter = issueFilterForPreset(state.selectedIssueFilter);
|
|
36162
37088
|
void safe(getIssueList(git, filter)).then((value) => {
|
|
36163
37089
|
if (!active)
|
|
@@ -36165,13 +37091,21 @@ function LogInkApp(deps) {
|
|
|
36165
37091
|
setContext((current) => ({
|
|
36166
37092
|
...current,
|
|
36167
37093
|
issueList: value,
|
|
36168
|
-
}));
|
|
36169
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
|
|
37094
|
+
}), issuedAtDepth);
|
|
37095
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'), issuedAtDepth);
|
|
36170
37096
|
});
|
|
36171
37097
|
return () => {
|
|
36172
37098
|
active = false;
|
|
36173
37099
|
};
|
|
36174
|
-
}, [
|
|
37100
|
+
}, [
|
|
37101
|
+
git,
|
|
37102
|
+
runtimes.length,
|
|
37103
|
+
state.activeView,
|
|
37104
|
+
context.issueList,
|
|
37105
|
+
state.selectedIssueFilter,
|
|
37106
|
+
setContext,
|
|
37107
|
+
setContextStatus,
|
|
37108
|
+
]);
|
|
36175
37109
|
// Filter cycling: when the preset changes, drop the cached list
|
|
36176
37110
|
// so the effect above re-fires with the new filter. Done as a
|
|
36177
37111
|
// separate effect (rather than folded into the cycle reducer)
|
|
@@ -36195,8 +37129,9 @@ function LogInkApp(deps) {
|
|
|
36195
37129
|
return;
|
|
36196
37130
|
if (context.pullRequestList)
|
|
36197
37131
|
return;
|
|
37132
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36198
37133
|
let active = true;
|
|
36199
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
|
|
37134
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'), issuedAtDepth);
|
|
36200
37135
|
const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
|
|
36201
37136
|
void safe(getPullRequestList(git, filter)).then((value) => {
|
|
36202
37137
|
if (!active)
|
|
@@ -36204,13 +37139,21 @@ function LogInkApp(deps) {
|
|
|
36204
37139
|
setContext((current) => ({
|
|
36205
37140
|
...current,
|
|
36206
37141
|
pullRequestList: value,
|
|
36207
|
-
}));
|
|
36208
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
|
|
37142
|
+
}), issuedAtDepth);
|
|
37143
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'), issuedAtDepth);
|
|
36209
37144
|
});
|
|
36210
37145
|
return () => {
|
|
36211
37146
|
active = false;
|
|
36212
37147
|
};
|
|
36213
|
-
}, [
|
|
37148
|
+
}, [
|
|
37149
|
+
git,
|
|
37150
|
+
runtimes.length,
|
|
37151
|
+
state.activeView,
|
|
37152
|
+
context.pullRequestList,
|
|
37153
|
+
state.selectedPullRequestFilter,
|
|
37154
|
+
setContext,
|
|
37155
|
+
setContextStatus,
|
|
37156
|
+
]);
|
|
36214
37157
|
React.useEffect(() => {
|
|
36215
37158
|
if (state.activeView !== 'pull-request-triage')
|
|
36216
37159
|
return;
|
|
@@ -36240,6 +37183,7 @@ function LogInkApp(deps) {
|
|
|
36240
37183
|
return;
|
|
36241
37184
|
if (context.issueDetailByNumber?.has(cursored.number))
|
|
36242
37185
|
return;
|
|
37186
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36243
37187
|
let active = true;
|
|
36244
37188
|
const timer = setTimeout(async () => {
|
|
36245
37189
|
const result = await getIssueDetail(cursored.number);
|
|
@@ -36248,17 +37192,19 @@ function LogInkApp(deps) {
|
|
|
36248
37192
|
setContext((current) => ({
|
|
36249
37193
|
...current,
|
|
36250
37194
|
issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
|
|
36251
|
-
}));
|
|
37195
|
+
}), issuedAtDepth);
|
|
36252
37196
|
}, DETAIL_HYDRATION_DELAY_MS);
|
|
36253
37197
|
return () => {
|
|
36254
37198
|
active = false;
|
|
36255
37199
|
clearTimeout(timer);
|
|
36256
37200
|
};
|
|
36257
37201
|
}, [
|
|
37202
|
+
runtimes.length,
|
|
36258
37203
|
state.activeView,
|
|
36259
37204
|
state.selectedIssueIndex,
|
|
36260
37205
|
filteredIssueList,
|
|
36261
37206
|
context.issueDetailByNumber,
|
|
37207
|
+
setContext,
|
|
36262
37208
|
]);
|
|
36263
37209
|
React.useEffect(() => {
|
|
36264
37210
|
if (state.activeView !== 'pull-request-triage')
|
|
@@ -36268,6 +37214,7 @@ function LogInkApp(deps) {
|
|
|
36268
37214
|
return;
|
|
36269
37215
|
if (context.pullRequestDetailByNumber?.has(cursored.number))
|
|
36270
37216
|
return;
|
|
37217
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36271
37218
|
let active = true;
|
|
36272
37219
|
const timer = setTimeout(async () => {
|
|
36273
37220
|
const result = await getPullRequestDetail(cursored.number);
|
|
@@ -36276,17 +37223,19 @@ function LogInkApp(deps) {
|
|
|
36276
37223
|
setContext((current) => ({
|
|
36277
37224
|
...current,
|
|
36278
37225
|
pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
|
|
36279
|
-
}));
|
|
37226
|
+
}), issuedAtDepth);
|
|
36280
37227
|
}, DETAIL_HYDRATION_DELAY_MS);
|
|
36281
37228
|
return () => {
|
|
36282
37229
|
active = false;
|
|
36283
37230
|
clearTimeout(timer);
|
|
36284
37231
|
};
|
|
36285
37232
|
}, [
|
|
37233
|
+
runtimes.length,
|
|
36286
37234
|
state.activeView,
|
|
36287
37235
|
state.selectedPullRequestTriageIndex,
|
|
36288
37236
|
filteredPullRequestTriageList,
|
|
36289
37237
|
context.pullRequestDetailByNumber,
|
|
37238
|
+
setContext,
|
|
36290
37239
|
]);
|
|
36291
37240
|
React.useEffect(() => {
|
|
36292
37241
|
let active = true;
|
|
@@ -36547,21 +37496,124 @@ function LogInkApp(deps) {
|
|
|
36547
37496
|
state.commitCompose.body,
|
|
36548
37497
|
state.commitCompose.summary,
|
|
36549
37498
|
]);
|
|
37499
|
+
// AbortController for the in-flight AI draft (#881 phase 3). Kept in
|
|
37500
|
+
// a ref rather than state because cancel is a side-effect: the input
|
|
37501
|
+
// handler reads `controllerRef.current?.abort()` synchronously when
|
|
37502
|
+
// Esc fires during a loading draft. Storing it in state would force
|
|
37503
|
+
// a re-render on every set, and React doesn't need to know — only
|
|
37504
|
+
// the imperative cancel path does. Cleared after each call settles
|
|
37505
|
+
// so a stale controller can't cancel a future draft.
|
|
37506
|
+
const aiDraftAbortRef = React.useRef(null);
|
|
36550
37507
|
const runAiCommitDraft = React.useCallback(async () => {
|
|
37508
|
+
// Tear down any controller from a previous draft (defensive — a
|
|
37509
|
+
// settled call should have cleared it in the finally block, but
|
|
37510
|
+
// double-running would otherwise leave the first orphaned).
|
|
37511
|
+
aiDraftAbortRef.current?.abort();
|
|
37512
|
+
const controller = new AbortController();
|
|
37513
|
+
aiDraftAbortRef.current = controller;
|
|
36551
37514
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
|
|
36552
37515
|
dispatch({ type: 'setStatus', value: 'generating AI commit draft', loading: true });
|
|
36553
|
-
|
|
36554
|
-
|
|
36555
|
-
|
|
36556
|
-
|
|
36557
|
-
|
|
37516
|
+
// Streaming preview (#881 phase 2). The workflow forwards this to
|
|
37517
|
+
// `generateCommitDraft`, which only actually streams when the
|
|
37518
|
+
// user opted in via `service.streaming.enabled`. The callback
|
|
37519
|
+
// updates `commitCompose.streamingPreview` so the compose surface
|
|
37520
|
+
// renders a live last-N-lines preview below the loader. The
|
|
37521
|
+
// reducer clears `streamingPreview` whenever loading flips off
|
|
37522
|
+
// (success or failure), so we don't need an explicit teardown
|
|
37523
|
+
// dispatch here.
|
|
37524
|
+
try {
|
|
37525
|
+
const result = await runCommitDraftWorkflow({
|
|
37526
|
+
git,
|
|
37527
|
+
signal: controller.signal,
|
|
37528
|
+
onStreamChunk: (_text, accumulated) => {
|
|
37529
|
+
// Audit finding #4: skip dispatching into a torn-down
|
|
37530
|
+
// tree. If the user quit (or otherwise unmounted the
|
|
37531
|
+
// workstation) mid-stream, React warns about updates on
|
|
37532
|
+
// an unmounted component. Drop the chunk silently.
|
|
37533
|
+
if (!mountedRef.current)
|
|
37534
|
+
return;
|
|
37535
|
+
// Dispatch the full accumulated text — the preview chrome
|
|
37536
|
+
// helper does the last-N-lines slicing at render time, so
|
|
37537
|
+
// re-doing the slice here would be wasted work. Per-chunk
|
|
37538
|
+
// dispatches are cheap; React batches them and Ink redraws
|
|
37539
|
+
// at its own frame cadence.
|
|
37540
|
+
dispatch({
|
|
37541
|
+
type: 'commitCompose',
|
|
37542
|
+
action: { type: 'setStreamingPreview', value: accumulated },
|
|
37543
|
+
});
|
|
37544
|
+
},
|
|
37545
|
+
});
|
|
37546
|
+
// Audit finding #4 (unmount race): bail out before any
|
|
37547
|
+
// post-await dispatch if the user quit while the LLM call was
|
|
37548
|
+
// in flight. Same pattern as `refreshHistoryRows` upstream.
|
|
37549
|
+
if (!mountedRef.current)
|
|
37550
|
+
return;
|
|
37551
|
+
// Cancel path (#881 phase 3). User pressed Esc during the
|
|
37552
|
+
// stream; reducer drops loading + preview, status line shows
|
|
37553
|
+
// a neutral "cancelled" message. Skip the result / failure
|
|
37554
|
+
// dispatches because the user already knows what happened.
|
|
37555
|
+
if (result.cancelled) {
|
|
37556
|
+
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37557
|
+
dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
|
|
37558
|
+
return;
|
|
37559
|
+
}
|
|
37560
|
+
if (result.ok && result.draft) {
|
|
37561
|
+
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
|
|
37562
|
+
dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
|
|
37563
|
+
return;
|
|
37564
|
+
}
|
|
37565
|
+
dispatch({
|
|
37566
|
+
type: 'commitCompose',
|
|
37567
|
+
action: { type: 'setResult', message: result.message, details: result.details },
|
|
37568
|
+
});
|
|
37569
|
+
dispatch({ type: 'setStatus', value: result.message });
|
|
36558
37570
|
}
|
|
36559
|
-
|
|
36560
|
-
|
|
36561
|
-
|
|
36562
|
-
|
|
36563
|
-
|
|
36564
|
-
|
|
37571
|
+
catch (error) {
|
|
37572
|
+
// Audit finding #3: defensive recovery for unexpected throws
|
|
37573
|
+
// from the workflow. The workflow catches its own errors
|
|
37574
|
+
// today, so this catch is latent — but any future refactor
|
|
37575
|
+
// that lets an error escape would otherwise strand the
|
|
37576
|
+
// spinner permanently with no user-facing recovery short of
|
|
37577
|
+
// quitting. Surface a generic failure and clear the loading
|
|
37578
|
+
// state so the user can re-try.
|
|
37579
|
+
if (mountedRef.current) {
|
|
37580
|
+
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37581
|
+
dispatch({
|
|
37582
|
+
type: 'setStatus',
|
|
37583
|
+
value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
37584
|
+
kind: 'error',
|
|
37585
|
+
});
|
|
37586
|
+
}
|
|
37587
|
+
}
|
|
37588
|
+
finally {
|
|
37589
|
+
// Clear the ref only if it still points at OUR controller — a
|
|
37590
|
+
// rapid second invocation could have already replaced it, in
|
|
37591
|
+
// which case the new controller is the one that owns cancel
|
|
37592
|
+
// duty now.
|
|
37593
|
+
if (aiDraftAbortRef.current === controller) {
|
|
37594
|
+
aiDraftAbortRef.current = null;
|
|
37595
|
+
}
|
|
37596
|
+
}
|
|
37597
|
+
}, [dispatch, git]);
|
|
37598
|
+
/**
|
|
37599
|
+
* Cancel an in-flight AI draft (#881 phase 3). Called by the input
|
|
37600
|
+
* handler when the user presses Esc while `commitCompose.loading`
|
|
37601
|
+
* is true. Idempotent — calling without an active controller is a
|
|
37602
|
+
* no-op rather than an error so the keystroke handler can fire
|
|
37603
|
+
* unconditionally during the loading window.
|
|
37604
|
+
*
|
|
37605
|
+
* `controller.abort()` propagates through
|
|
37606
|
+
* `executeChainStreaming`, which throws `LangChainCancelledError`,
|
|
37607
|
+
* which becomes `cancelled: true` on the workflow result. The
|
|
37608
|
+
* runAiCommitDraft promise's finally block clears the ref. The
|
|
37609
|
+
* resulting cleanup dispatches (clearing loading + status) happen
|
|
37610
|
+
* back in `runAiCommitDraft`, not here, so this function stays
|
|
37611
|
+
* pure-imperative and the React state updates flow through a
|
|
37612
|
+
* single code path.
|
|
37613
|
+
*/
|
|
37614
|
+
const cancelAiCommitDraft = React.useCallback(() => {
|
|
37615
|
+
aiDraftAbortRef.current?.abort();
|
|
37616
|
+
}, []);
|
|
36565
37617
|
// `C` keystroke handler — start the create-pull-request flow. Resolves
|
|
36566
37618
|
// the head + base branches from the live context, runs
|
|
36567
37619
|
// `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
|
|
@@ -36575,6 +37627,19 @@ function LogInkApp(deps) {
|
|
|
36575
37627
|
// missing) we surface the failure on the status line and skip the
|
|
36576
37628
|
// prompt entirely — better than opening a prompt the user can't
|
|
36577
37629
|
// actually submit successfully.
|
|
37630
|
+
// Soft-cancel handle for the PR body draft (#881 phase 4). A mutable
|
|
37631
|
+
// ref rather than state because the cancel decision needs to be
|
|
37632
|
+
// visible synchronously inside the async workflow without forcing
|
|
37633
|
+
// re-renders. Owned by the in-flight invocation: the cancel callback
|
|
37634
|
+
// mutates `.cancelled` on the live ref; the workflow checks it after
|
|
37635
|
+
// `await` resolves and decides whether to open the follow-up prompt.
|
|
37636
|
+
//
|
|
37637
|
+
// The LLM call itself keeps running (no AbortSignal threaded through
|
|
37638
|
+
// `changelogHandler` today). The user-visible outcome — "PR draft
|
|
37639
|
+
// cancelled, no prompt opens" — is identical to a hard cancel, at
|
|
37640
|
+
// the cost of paying for the in-flight tokens. Deeper threading
|
|
37641
|
+
// lands in a follow-up if hard cancel becomes a request.
|
|
37642
|
+
const pullRequestBodyCancelRef = React.useRef(null);
|
|
36578
37643
|
const startCreatePullRequest = React.useCallback(async () => {
|
|
36579
37644
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
36580
37645
|
if (!head) {
|
|
@@ -36603,32 +37668,78 @@ function LogInkApp(deps) {
|
|
|
36603
37668
|
});
|
|
36604
37669
|
return;
|
|
36605
37670
|
}
|
|
37671
|
+
// Set up the cancel handle BEFORE flipping the pending flag so a
|
|
37672
|
+
// race between the flag-set and a synchronous Esc keystroke can't
|
|
37673
|
+
// leave the input handler dispatching cancel without a ref to
|
|
37674
|
+
// mutate. The cancel callback no-ops cleanly when the ref is null
|
|
37675
|
+
// (call already settled).
|
|
37676
|
+
const cancelHandle = { cancelled: false };
|
|
37677
|
+
pullRequestBodyCancelRef.current = cancelHandle;
|
|
37678
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
|
|
37679
|
+
// Audit finding #6: soft cancel today — Esc skips opening the
|
|
37680
|
+
// follow-up prompt, but the LLM call itself keeps running to
|
|
37681
|
+
// completion (no AbortSignal threaded through the changelog CLI
|
|
37682
|
+
// chain). Status copy reflects that honestly so the user isn't
|
|
37683
|
+
// misled into thinking they're saving tokens.
|
|
36606
37684
|
dispatch({
|
|
36607
37685
|
type: 'setStatus',
|
|
36608
|
-
value: `generating PR body from changelog (vs ${defaultBranch})
|
|
37686
|
+
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
|
|
36609
37687
|
loading: true,
|
|
36610
37688
|
});
|
|
36611
|
-
|
|
36612
|
-
|
|
36613
|
-
|
|
36614
|
-
|
|
36615
|
-
|
|
36616
|
-
|
|
36617
|
-
|
|
36618
|
-
|
|
36619
|
-
|
|
36620
|
-
|
|
37689
|
+
try {
|
|
37690
|
+
const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
|
|
37691
|
+
// Soft-cancel check (#881 phase 4). If the user pressed Esc
|
|
37692
|
+
// while the workflow was awaiting, skip opening the prompt and
|
|
37693
|
+
// surface a neutral status. The underlying LLM call has
|
|
37694
|
+
// already settled — its result is discarded. Hard cancel
|
|
37695
|
+
// (aborting the HTTP request mid-flight) is a follow-up.
|
|
37696
|
+
if (cancelHandle.cancelled) {
|
|
37697
|
+
dispatch({ type: 'setStatus', value: 'PR draft cancelled.' });
|
|
37698
|
+
return;
|
|
37699
|
+
}
|
|
37700
|
+
// Fallback shape when the changelog generation fails — open the
|
|
37701
|
+
// prompt with empty title + body rather than aborting, so the user
|
|
37702
|
+
// can still author the PR manually. The status line surfaces why
|
|
37703
|
+
// we couldn't pre-fill.
|
|
37704
|
+
const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
|
|
37705
|
+
const initialBody = body.body || '';
|
|
37706
|
+
const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
|
|
37707
|
+
if (!body.ok) {
|
|
37708
|
+
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
|
|
37709
|
+
}
|
|
37710
|
+
else {
|
|
37711
|
+
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
37712
|
+
}
|
|
37713
|
+
// Audit finding #11: clear the pending flag BEFORE opening the
|
|
37714
|
+
// prompt. If a future refactor adds an `await` between the flag
|
|
37715
|
+
// clear (currently in `finally`) and the `openInputPrompt`
|
|
37716
|
+
// dispatch, an Esc keystroke in the gap would dispatch
|
|
37717
|
+
// `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
|
|
37718
|
+
// the prompt visible with a stale "cancelled" message. Clearing
|
|
37719
|
+
// here moves the flag teardown into the same React batch as the
|
|
37720
|
+
// prompt open, eliminating the race.
|
|
37721
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37722
|
+
dispatch({
|
|
37723
|
+
type: 'openInputPrompt',
|
|
37724
|
+
kind: 'create-pr',
|
|
37725
|
+
label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
|
|
37726
|
+
initial,
|
|
37727
|
+
multiline: true,
|
|
37728
|
+
});
|
|
36621
37729
|
}
|
|
36622
|
-
|
|
36623
|
-
|
|
37730
|
+
finally {
|
|
37731
|
+
// Belt-and-suspenders: the `try` block clears the flag on the
|
|
37732
|
+
// success path (audit finding #11). This duplicate clear handles
|
|
37733
|
+
// the error / cancel paths where the early-returns skip the
|
|
37734
|
+
// success-path dispatch. Safe to no-op when already false.
|
|
37735
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37736
|
+
// Only clear the ref if we still own it — a second invocation
|
|
37737
|
+
// would have already taken ownership in which case the cancel
|
|
37738
|
+
// duty has rolled over.
|
|
37739
|
+
if (pullRequestBodyCancelRef.current === cancelHandle) {
|
|
37740
|
+
pullRequestBodyCancelRef.current = null;
|
|
37741
|
+
}
|
|
36624
37742
|
}
|
|
36625
|
-
dispatch({
|
|
36626
|
-
type: 'openInputPrompt',
|
|
36627
|
-
kind: 'create-pr',
|
|
36628
|
-
label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
|
|
36629
|
-
initial,
|
|
36630
|
-
multiline: true,
|
|
36631
|
-
});
|
|
36632
37743
|
}, [
|
|
36633
37744
|
context.branches?.currentBranch,
|
|
36634
37745
|
context.provider?.currentBranch,
|
|
@@ -36637,6 +37748,24 @@ function LogInkApp(deps) {
|
|
|
36637
37748
|
context.pullRequest?.currentPullRequest,
|
|
36638
37749
|
dispatch,
|
|
36639
37750
|
]);
|
|
37751
|
+
/**
|
|
37752
|
+
* Soft-cancel the in-flight PR body draft (#881 phase 4). The
|
|
37753
|
+
* cancel ref's `.cancelled` flag is checked after the workflow's
|
|
37754
|
+
* await resolves; setting it true causes the workflow to skip the
|
|
37755
|
+
* prompt-open and surface a neutral "cancelled" status. The LLM
|
|
37756
|
+
* call itself isn't aborted (no signal threaded through the
|
|
37757
|
+
* `changelogHandler` chain) so the user still pays for the in-flight
|
|
37758
|
+
* tokens. Acceptable for a 5-15s draft; hard cancel lands in a
|
|
37759
|
+
* follow-up if it becomes a real ask.
|
|
37760
|
+
*
|
|
37761
|
+
* Idempotent — calling without an active draft is a no-op.
|
|
37762
|
+
*/
|
|
37763
|
+
const cancelPullRequestBodyDraft = React.useCallback(() => {
|
|
37764
|
+
const handle = pullRequestBodyCancelRef.current;
|
|
37765
|
+
if (!handle)
|
|
37766
|
+
return;
|
|
37767
|
+
handle.cancelled = true;
|
|
37768
|
+
}, []);
|
|
36640
37769
|
// Copy an arbitrary string to the system clipboard. Distinct from
|
|
36641
37770
|
// `yankFromActiveView` which derives the value from the current view
|
|
36642
37771
|
// — this one takes the value as an explicit event payload, used by
|
|
@@ -36707,6 +37836,11 @@ function LogInkApp(deps) {
|
|
|
36707
37836
|
branch: head,
|
|
36708
37837
|
baseLabel: cached.baseLabel,
|
|
36709
37838
|
text: cached.text,
|
|
37839
|
+
// Audit finding #9: cache-hit path preserves the original
|
|
37840
|
+
// generation timestamp rather than minting a fresh one — the
|
|
37841
|
+
// "X ago" header should reflect when the LLM ran, not when
|
|
37842
|
+
// the cached entry was re-displayed.
|
|
37843
|
+
generatedAt: cached.generatedAt,
|
|
36710
37844
|
});
|
|
36711
37845
|
dispatch({
|
|
36712
37846
|
type: 'setStatus',
|
|
@@ -36735,6 +37869,9 @@ function LogInkApp(deps) {
|
|
|
36735
37869
|
branch: head,
|
|
36736
37870
|
baseLabel,
|
|
36737
37871
|
text: result.text,
|
|
37872
|
+
// Audit finding #9: timestamp captured at dispatch time, not
|
|
37873
|
+
// inside the reducer.
|
|
37874
|
+
generatedAt: Date.now(),
|
|
36738
37875
|
});
|
|
36739
37876
|
dispatch({
|
|
36740
37877
|
type: 'setStatus',
|
|
@@ -36837,7 +37974,7 @@ function LogInkApp(deps) {
|
|
|
36837
37974
|
if (editorOk) {
|
|
36838
37975
|
try {
|
|
36839
37976
|
const content = fs$1.readFileSync(file, 'utf8');
|
|
36840
|
-
dispatch({ type: 'setChangelogText', text: content });
|
|
37977
|
+
dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
|
|
36841
37978
|
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
36842
37979
|
}
|
|
36843
37980
|
catch (error) {
|
|
@@ -37062,11 +38199,18 @@ function LogInkApp(deps) {
|
|
|
37062
38199
|
type: 'setSplitPlanReady',
|
|
37063
38200
|
plan: result.plan,
|
|
37064
38201
|
planContext: result.planContext,
|
|
38202
|
+
fallback: result.fallback,
|
|
37065
38203
|
});
|
|
38204
|
+
const readyMessage = result.fallback
|
|
38205
|
+
? `Split planner exhausted retries — showing single-commit fallback. y/Enter to apply as one commit, r to re-roll, Esc to cancel.`
|
|
38206
|
+
: `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`;
|
|
38207
|
+
// Use 'info' kind for the fallback path (still actionable, just
|
|
38208
|
+
// not a clean win). The reducer's "warning" is the absence of
|
|
38209
|
+
// `success` framing — the message text itself carries the cue.
|
|
37066
38210
|
dispatch({
|
|
37067
38211
|
type: 'setStatus',
|
|
37068
|
-
value:
|
|
37069
|
-
kind: 'success',
|
|
38212
|
+
value: readyMessage,
|
|
38213
|
+
kind: result.fallback ? 'info' : 'success',
|
|
37070
38214
|
});
|
|
37071
38215
|
}, [context.operation, context.worktree?.stagedCount, dispatch, git]);
|
|
37072
38216
|
// `y`/Enter inside the overlay — apply the previewed plan. Uses the
|
|
@@ -37108,6 +38252,7 @@ function LogInkApp(deps) {
|
|
|
37108
38252
|
plan: splitPlan.plan,
|
|
37109
38253
|
planContext: splitPlan.planContext,
|
|
37110
38254
|
git,
|
|
38255
|
+
fallback: splitPlan.fallback,
|
|
37111
38256
|
});
|
|
37112
38257
|
dump.push(`workflow returned: ok=${result.ok} message="${result.message}" commitHashes=[${(result.commitHashes || []).join(', ')}]`);
|
|
37113
38258
|
try {
|
|
@@ -37184,7 +38329,8 @@ function LogInkApp(deps) {
|
|
|
37184
38329
|
// that could disagree with reality on partial-apply.
|
|
37185
38330
|
const commitHashes = result.commitHashes || [];
|
|
37186
38331
|
if (commitHashes.length > 0) {
|
|
37187
|
-
|
|
38332
|
+
// Audit finding #9: timestamp captured at dispatch time.
|
|
38333
|
+
dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
|
|
37188
38334
|
// DevSkim: ignore DS172411 — function literal, fixed delay,
|
|
37189
38335
|
// no caller-supplied data flowing through.
|
|
37190
38336
|
setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
|
|
@@ -37202,8 +38348,15 @@ function LogInkApp(deps) {
|
|
|
37202
38348
|
});
|
|
37203
38349
|
return;
|
|
37204
38350
|
}
|
|
37205
|
-
const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
|
|
37206
|
-
|
|
38351
|
+
const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked, result.fallback ? { reason: result.fallback.reason } : undefined);
|
|
38352
|
+
// Fallback path uses 'info' kind — apply technically succeeded
|
|
38353
|
+
// but the user should know it landed as a single combined commit
|
|
38354
|
+
// rather than a real LLM-driven multi-group split.
|
|
38355
|
+
dispatch({
|
|
38356
|
+
type: 'setStatus',
|
|
38357
|
+
value: successMessage,
|
|
38358
|
+
kind: result.fallback ? 'info' : 'success',
|
|
38359
|
+
});
|
|
37207
38360
|
}, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
|
|
37208
38361
|
// Esc inside the overlay — close without applying. Status line gets
|
|
37209
38362
|
// a confirmation so the user knows the operation was abandoned.
|
|
@@ -38663,9 +39816,15 @@ function LogInkApp(deps) {
|
|
|
38663
39816
|
else if (event.type === 'runAiCommitDraft') {
|
|
38664
39817
|
void runAiCommitDraft();
|
|
38665
39818
|
}
|
|
39819
|
+
else if (event.type === 'cancelAiCommitDraft') {
|
|
39820
|
+
cancelAiCommitDraft();
|
|
39821
|
+
}
|
|
38666
39822
|
else if (event.type === 'startCreatePullRequest') {
|
|
38667
39823
|
void startCreatePullRequest();
|
|
38668
39824
|
}
|
|
39825
|
+
else if (event.type === 'cancelPullRequestBodyDraft') {
|
|
39826
|
+
cancelPullRequestBodyDraft();
|
|
39827
|
+
}
|
|
38669
39828
|
else if (event.type === 'startChangelogView') {
|
|
38670
39829
|
void startChangelogView();
|
|
38671
39830
|
}
|