git-coco 0.52.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +47 -0
- package/dist/index.esm.mjs +1364 -156
- package/dist/index.js +1364 -156
- package/package.json +10 -7
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.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -325,6 +325,25 @@ class LangChainNetworkError extends LangChainError {
|
|
|
325
325
|
this.provider = provider;
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* User-initiated cancellation (#881 phase 3). Thrown by streaming
|
|
330
|
+
* helpers when an `AbortSignal` they were given fires. Distinct from
|
|
331
|
+
* `LangChainNetworkError` / `LangChainTimeoutError` so callers can
|
|
332
|
+
* pattern-match: a cancelled LLM call is the user's intent, not a
|
|
333
|
+
* failure to surface in the status line as an error.
|
|
334
|
+
*
|
|
335
|
+
* Carries the accumulated text up to the cancel point (when
|
|
336
|
+
* available) so the caller can decide whether to salvage a partial
|
|
337
|
+
* result or discard it. Today the workstation discards — the
|
|
338
|
+
* preview pane was the only consumer of the accumulated text and it
|
|
339
|
+
* gets cleared on cancel anyway.
|
|
340
|
+
*/
|
|
341
|
+
class LangChainCancelledError extends LangChainError {
|
|
342
|
+
constructor(message, accumulated, context) {
|
|
343
|
+
super(message, { ...context, accumulated });
|
|
344
|
+
this.accumulated = accumulated;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
328
347
|
|
|
329
348
|
/**
|
|
330
349
|
* Validates that a required parameter is not null or undefined
|
|
@@ -1319,6 +1338,18 @@ const schema$1 = {
|
|
|
1319
1338
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1320
1339
|
"default": "balanced"
|
|
1321
1340
|
},
|
|
1341
|
+
"streaming": {
|
|
1342
|
+
"type": "object",
|
|
1343
|
+
"properties": {
|
|
1344
|
+
"enabled": {
|
|
1345
|
+
"type": "boolean",
|
|
1346
|
+
"description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
|
|
1347
|
+
"default": false
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
"additionalProperties": false,
|
|
1351
|
+
"description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
|
|
1352
|
+
},
|
|
1322
1353
|
"fastPath": {
|
|
1323
1354
|
"type": "object",
|
|
1324
1355
|
"properties": {
|
|
@@ -1773,6 +1804,18 @@ const schema$1 = {
|
|
|
1773
1804
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1774
1805
|
"default": "balanced"
|
|
1775
1806
|
},
|
|
1807
|
+
"streaming": {
|
|
1808
|
+
"type": "object",
|
|
1809
|
+
"properties": {
|
|
1810
|
+
"enabled": {
|
|
1811
|
+
"type": "boolean",
|
|
1812
|
+
"description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
|
|
1813
|
+
"default": false
|
|
1814
|
+
}
|
|
1815
|
+
},
|
|
1816
|
+
"additionalProperties": false,
|
|
1817
|
+
"description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
|
|
1818
|
+
},
|
|
1776
1819
|
"fastPath": {
|
|
1777
1820
|
"type": "object",
|
|
1778
1821
|
"properties": {
|
|
@@ -1967,6 +2010,18 @@ const schema$1 = {
|
|
|
1967
2010
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1968
2011
|
"default": "balanced"
|
|
1969
2012
|
},
|
|
2013
|
+
"streaming": {
|
|
2014
|
+
"type": "object",
|
|
2015
|
+
"properties": {
|
|
2016
|
+
"enabled": {
|
|
2017
|
+
"type": "boolean",
|
|
2018
|
+
"description": "Master switch. When `false` (default) every LLM call uses the existing non-streaming code path, regardless of which command or surface fires it.",
|
|
2019
|
+
"default": false
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
"additionalProperties": false,
|
|
2023
|
+
"description": "Streaming output (#881). Wires `chain.stream()` instead of `chain.invoke()` into LLM-driven TUI surfaces so the user sees a live preview of the model's output as it generates, rather than staring at a spinner until the full response arrives.\n\nOutput contract is unchanged when enabled: the final draft / plan still goes through the same parser, schema validator, and retry logic as the non-streaming path. The stream is a *preview only* — it relieves the \"is this hanging?\" anxiety without touching what gets committed.\n\nOff by default while we shake the UX out across providers; some models stream poorly (one-shot blob disguised as a stream) and the preview just blinks in those cases. Off-by-default also lets users who prefer the quieter spinner-only UX skip the visual chatter.\n\nScope today: workstation compose surface's AI commit draft (the `I` keystroke). Other TUI LLM calls (split-plan, PR body) stay non-streaming pending separate validation."
|
|
2024
|
+
},
|
|
1970
2025
|
"fastPath": {
|
|
1971
2026
|
"type": "object",
|
|
1972
2027
|
"properties": {
|
|
@@ -7960,7 +8015,7 @@ async function enforcePromptBudget({ prompt, variables, tokenizer, maxTokens, su
|
|
|
7960
8015
|
/**
|
|
7961
8016
|
* Extracts provider and endpoint info from LLM instance if available
|
|
7962
8017
|
*/
|
|
7963
|
-
function extractLlmInfo(llm) {
|
|
8018
|
+
function extractLlmInfo$1(llm) {
|
|
7964
8019
|
const info = {};
|
|
7965
8020
|
// Try to extract provider from class name
|
|
7966
8021
|
const className = llm?.constructor?.name || '';
|
|
@@ -8003,7 +8058,7 @@ const executeChain = async ({ llm, prompt, variables, parser, provider, endpoint
|
|
|
8003
8058
|
});
|
|
8004
8059
|
}
|
|
8005
8060
|
// Extract LLM info for error reporting if not provided
|
|
8006
|
-
const llmInfo = extractLlmInfo(llm);
|
|
8061
|
+
const llmInfo = extractLlmInfo$1(llm);
|
|
8007
8062
|
const effectiveProvider = provider || llmInfo.provider;
|
|
8008
8063
|
const effectiveEndpoint = endpoint || llmInfo.endpoint;
|
|
8009
8064
|
try {
|
|
@@ -14588,6 +14643,11 @@ const options$8 = {
|
|
|
14588
14643
|
type: 'boolean',
|
|
14589
14644
|
default: false,
|
|
14590
14645
|
},
|
|
14646
|
+
strictSplit: {
|
|
14647
|
+
description: 'Fail loudly if the split planner exhausts its retry budget with an invalid plan (otherwise falls back to a single combined commit).',
|
|
14648
|
+
type: 'boolean',
|
|
14649
|
+
default: false,
|
|
14650
|
+
},
|
|
14591
14651
|
};
|
|
14592
14652
|
const builder$8 = (yargs) => {
|
|
14593
14653
|
return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
|
|
@@ -15476,6 +15536,53 @@ function dropEmptyGroups(plan) {
|
|
|
15476
15536
|
}
|
|
15477
15537
|
return { ...plan, groups: surviving };
|
|
15478
15538
|
}
|
|
15539
|
+
/**
|
|
15540
|
+
* Construct a trivially-valid single-group plan covering every staged
|
|
15541
|
+
* file. Used as the fallback when the LLM exhausts its retry budget
|
|
15542
|
+
* with an invalid plan — turning a hard failure into a usable
|
|
15543
|
+
* (if degraded) outcome.
|
|
15544
|
+
*
|
|
15545
|
+
* Properties of the returned plan:
|
|
15546
|
+
*
|
|
15547
|
+
* - Exactly one group.
|
|
15548
|
+
* - Every staged file appears in that group's `files[]`. No hunks
|
|
15549
|
+
* are claimed, so any hunk inventory is irrelevant to the plan's
|
|
15550
|
+
* validity.
|
|
15551
|
+
* - By construction: no duplicates, no missing files, no mixed
|
|
15552
|
+
* mode, no phantom hunks. `getPlanValidationIssues` returns an
|
|
15553
|
+
* empty issue set.
|
|
15554
|
+
*
|
|
15555
|
+
* The group's `rationale` carries the reason text the caller wants
|
|
15556
|
+
* to expose to the UI (typically "model exhausted N attempts; last
|
|
15557
|
+
* issues were …"). The `body` carries a short note that survives
|
|
15558
|
+
* into the commit message body so a user who applies without editing
|
|
15559
|
+
* has the context recorded in git history.
|
|
15560
|
+
*
|
|
15561
|
+
* `title` defaults to a generic conventional-commits-compatible
|
|
15562
|
+
* `chore: combined commit` — bland on purpose. Real commit messaging
|
|
15563
|
+
* is the user's job at the compose / apply step.
|
|
15564
|
+
*
|
|
15565
|
+
* The plan is NOT linked to the LLM by construction. If the model
|
|
15566
|
+
* can't produce a valid split, the user still gets one apply-able
|
|
15567
|
+
* commit instead of a thrown error and a still-staged worktree.
|
|
15568
|
+
*/
|
|
15569
|
+
function buildSplitPlanFallback(staged, options = {}) {
|
|
15570
|
+
const files = staged.map((change) => change.filePath);
|
|
15571
|
+
const reasonLine = options.reason
|
|
15572
|
+
? ` Reason: ${options.reason}`
|
|
15573
|
+
: '';
|
|
15574
|
+
return {
|
|
15575
|
+
groups: [
|
|
15576
|
+
{
|
|
15577
|
+
title: 'chore: combined commit',
|
|
15578
|
+
body: 'Auto-generated single-commit fallback after the split planner could not produce a valid multi-group plan. Edit before applying if you want a more specific message; press `r` to re-roll the planner if a different model might do better.',
|
|
15579
|
+
rationale: `Fallback plan — every staged file in one commit because the LLM could not produce a valid multi-group split.${reasonLine}`,
|
|
15580
|
+
files,
|
|
15581
|
+
hunks: [],
|
|
15582
|
+
},
|
|
15583
|
+
],
|
|
15584
|
+
};
|
|
15585
|
+
}
|
|
15479
15586
|
function formatPlanValidationFeedback(issues) {
|
|
15480
15587
|
const sections = [];
|
|
15481
15588
|
if (issues.unknownFiles.length) {
|
|
@@ -15512,7 +15619,7 @@ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
|
|
|
15512
15619
|
* into the same prompt template (`previous_attempt_feedback` slot) so the model can
|
|
15513
15620
|
* fix its own mistakes without re-running pre-processing.
|
|
15514
15621
|
*/
|
|
15515
|
-
async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
|
|
15622
|
+
async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, strict = false, }) {
|
|
15516
15623
|
let lastIssues = null;
|
|
15517
15624
|
let attempt = 0;
|
|
15518
15625
|
while (attempt < maxAttempts) {
|
|
@@ -15582,9 +15689,42 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
|
|
|
15582
15689
|
logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
|
|
15583
15690
|
}
|
|
15584
15691
|
}
|
|
15585
|
-
|
|
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
|
/**
|
|
@@ -15629,6 +15769,7 @@ Structural rules:
|
|
|
15629
15769
|
- Only use hunk IDs LITERALLY copied from the "Staged hunk inventory" section below. Do not invent or guess hunk IDs.
|
|
15630
15770
|
- If the hunk inventory says "No hunk-level inventory available" then EVERY group's "hunks" array MUST be empty (use only "files"). Do not write hunk IDs like "path::hunk-1" when no hunk inventory exists — those are not valid.
|
|
15631
15771
|
- Prefer 2-5 commits unless the changes are truly all one topic.
|
|
15772
|
+
- Order the groups in the sequence they would logically be built — foundational changes first, consumers after. If group B uses a symbol, function, type, or file introduced in group A, A MUST appear before B in the array. The applier commits in array order, so this order becomes the git history. Example: a "feat: add helpers" group that introduces \`formatX()\` must come before a "feat: wire helpers into renderer" group that calls \`formatX()\`, even if the staged diff is presented in the opposite order. When two groups have no dependency relationship, prefer the one closer to a "scaffold" (types, config, new files) before the one closer to a "use site" (existing files modified to consume the new code).
|
|
15632
15773
|
|
|
15633
15774
|
Commit message style:
|
|
15634
15775
|
- Write each "title" in the imperative mood ("add", not "added"), under 72 chars.
|
|
@@ -15785,7 +15926,7 @@ async function applyPatchToIndex$1(patch, git) {
|
|
|
15785
15926
|
child.stdin.end();
|
|
15786
15927
|
});
|
|
15787
15928
|
}
|
|
15788
|
-
async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, }) {
|
|
15929
|
+
async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger, noVerify, fallback, }) {
|
|
15789
15930
|
validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
|
|
15790
15931
|
assertNoUnstagedOverlap(plan, changes, hunkInventory);
|
|
15791
15932
|
// Defensive: drop any group with empty files[] AND empty hunks[].
|
|
@@ -15888,11 +16029,13 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
|
|
|
15888
16029
|
return {
|
|
15889
16030
|
commitHashes,
|
|
15890
16031
|
message: `Created ${commitHashes.length} of ${applicableGroups.length} planned commit(s). Failed: ${partial}`,
|
|
16032
|
+
fallback,
|
|
15891
16033
|
};
|
|
15892
16034
|
}
|
|
15893
16035
|
return {
|
|
15894
16036
|
commitHashes,
|
|
15895
16037
|
message: `Created ${commitHashes.length} split commit(s).`,
|
|
16038
|
+
fallback,
|
|
15896
16039
|
};
|
|
15897
16040
|
}
|
|
15898
16041
|
/**
|
|
@@ -15990,7 +16133,7 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15990
16133
|
}
|
|
15991
16134
|
const resolvedPlanLlm = planLlm ?? llm;
|
|
15992
16135
|
const resolvedPlanModel = planService?.model ?? config.service.model;
|
|
15993
|
-
const { plan } = await generateValidatedCommitSplitPlan({
|
|
16136
|
+
const { plan, fallback } = await generateValidatedCommitSplitPlan({
|
|
15994
16137
|
llm: resolvedPlanLlm,
|
|
15995
16138
|
prompt: COMMIT_SPLIT_PROMPT,
|
|
15996
16139
|
variables: {
|
|
@@ -16013,8 +16156,12 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
16013
16156
|
conventional: useConventional,
|
|
16014
16157
|
},
|
|
16015
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),
|
|
16016
16163
|
});
|
|
16017
|
-
return { plan, context: { changes, hunkInventory } };
|
|
16164
|
+
return { plan, context: { changes, hunkInventory }, fallback };
|
|
16018
16165
|
}
|
|
16019
16166
|
async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
|
|
16020
16167
|
const result = await prepareCommitSplitPlan({
|
|
@@ -16030,7 +16177,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
|
|
|
16030
16177
|
if ('empty' in result) {
|
|
16031
16178
|
return 'No staged changes found.';
|
|
16032
16179
|
}
|
|
16033
|
-
const { plan, context } = result;
|
|
16180
|
+
const { plan, context, fallback } = result;
|
|
16034
16181
|
if (argv.apply) {
|
|
16035
16182
|
const applied = await applyCommitSplitPlan({
|
|
16036
16183
|
plan,
|
|
@@ -16039,9 +16186,24 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, pl
|
|
|
16039
16186
|
git,
|
|
16040
16187
|
logger,
|
|
16041
16188
|
noVerify: argv.noVerify || config.noVerify || false,
|
|
16189
|
+
fallback,
|
|
16042
16190
|
});
|
|
16191
|
+
if (applied.fallback) {
|
|
16192
|
+
return [
|
|
16193
|
+
`Note: applied the single-commit fallback (${applied.fallback.reason}).`,
|
|
16194
|
+
applied.message,
|
|
16195
|
+
].join('\n');
|
|
16196
|
+
}
|
|
16043
16197
|
return applied.message;
|
|
16044
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
|
+
}
|
|
16045
16207
|
return formatCommitSplitPlan(plan);
|
|
16046
16208
|
}
|
|
16047
16209
|
|
|
@@ -20080,9 +20242,13 @@ function applyCommitComposeAction(state, action) {
|
|
|
20080
20242
|
editing: action.value,
|
|
20081
20243
|
};
|
|
20082
20244
|
case 'setLoading':
|
|
20245
|
+
// Clearing loading also clears any in-flight streaming preview;
|
|
20246
|
+
// the preview's whole purpose is to fill the wait window. Once
|
|
20247
|
+
// the wait ends (success OR failure), the preview is stale.
|
|
20083
20248
|
return {
|
|
20084
20249
|
...state,
|
|
20085
20250
|
loading: action.value,
|
|
20251
|
+
streamingPreview: action.value ? state.streamingPreview : undefined,
|
|
20086
20252
|
};
|
|
20087
20253
|
case 'setDraft':
|
|
20088
20254
|
// No `message` here — the loader → filled fields are the confirmation
|
|
@@ -20098,6 +20264,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20098
20264
|
loading: false,
|
|
20099
20265
|
message: undefined,
|
|
20100
20266
|
details: undefined,
|
|
20267
|
+
streamingPreview: undefined,
|
|
20101
20268
|
};
|
|
20102
20269
|
case 'setResult':
|
|
20103
20270
|
return {
|
|
@@ -20105,6 +20272,17 @@ function applyCommitComposeAction(state, action) {
|
|
|
20105
20272
|
loading: false,
|
|
20106
20273
|
message: action.message,
|
|
20107
20274
|
details: action.details,
|
|
20275
|
+
streamingPreview: undefined,
|
|
20276
|
+
};
|
|
20277
|
+
case 'setStreamingPreview':
|
|
20278
|
+
// Per-chunk live-preview update. Fires from the streaming
|
|
20279
|
+
// workflow's onChunk callback; the renderer turns it into a
|
|
20280
|
+
// last-N-lines panel below the loading line. Pass `undefined`
|
|
20281
|
+
// to explicitly clear (the workflow does this on completion
|
|
20282
|
+
// alongside the `setDraft` / `setResult` dispatch).
|
|
20283
|
+
return {
|
|
20284
|
+
...state,
|
|
20285
|
+
streamingPreview: action.value,
|
|
20108
20286
|
};
|
|
20109
20287
|
case 'reset':
|
|
20110
20288
|
// Drop message/details too — the post-commit "Created commit ..."
|
|
@@ -20178,6 +20356,210 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
|
|
|
20178
20356
|
}
|
|
20179
20357
|
}
|
|
20180
20358
|
|
|
20359
|
+
/**
|
|
20360
|
+
* Same provider / endpoint best-effort extraction `executeChain` uses,
|
|
20361
|
+
* duplicated here rather than imported so the streaming module doesn't
|
|
20362
|
+
* pull on `executeChain`'s implementation. If both helpers ever need to
|
|
20363
|
+
* share more, factor this out to a shared `llmInfo.ts`.
|
|
20364
|
+
*/
|
|
20365
|
+
function extractLlmInfo(llm) {
|
|
20366
|
+
const info = {};
|
|
20367
|
+
const className = llm?.constructor?.name || '';
|
|
20368
|
+
if (className.includes('Ollama')) {
|
|
20369
|
+
info.provider = 'ollama';
|
|
20370
|
+
if ('lc_kwargs' in llm && typeof llm.lc_kwargs === 'object' && llm.lc_kwargs !== null) {
|
|
20371
|
+
const kwargs = llm.lc_kwargs;
|
|
20372
|
+
if (typeof kwargs.baseUrl === 'string') {
|
|
20373
|
+
info.endpoint = kwargs.baseUrl;
|
|
20374
|
+
}
|
|
20375
|
+
}
|
|
20376
|
+
}
|
|
20377
|
+
else if (className.includes('OpenAI')) {
|
|
20378
|
+
info.provider = 'openai';
|
|
20379
|
+
}
|
|
20380
|
+
else if (className.includes('Anthropic')) {
|
|
20381
|
+
info.provider = 'anthropic';
|
|
20382
|
+
}
|
|
20383
|
+
return info;
|
|
20384
|
+
}
|
|
20385
|
+
/**
|
|
20386
|
+
* Coerce one streamed chunk into its text fragment. LangChain's
|
|
20387
|
+
* `prompt.pipe(llm).stream(...)` yields `BaseMessageChunk` instances
|
|
20388
|
+
* whose `.content` is sometimes a string and sometimes an array of
|
|
20389
|
+
* content parts (multi-modal models, tool calls). We only care about
|
|
20390
|
+
* the textual delta here; non-text parts are silently dropped because
|
|
20391
|
+
* phase 1's surfaces (stdout + status-line copy) can't render them
|
|
20392
|
+
* anyway.
|
|
20393
|
+
*/
|
|
20394
|
+
function coerceChunkText(messageChunk) {
|
|
20395
|
+
if (typeof messageChunk === 'string')
|
|
20396
|
+
return messageChunk;
|
|
20397
|
+
if (messageChunk && typeof messageChunk === 'object' && 'content' in messageChunk) {
|
|
20398
|
+
const content = messageChunk.content;
|
|
20399
|
+
if (typeof content === 'string')
|
|
20400
|
+
return content;
|
|
20401
|
+
if (Array.isArray(content)) {
|
|
20402
|
+
// Multi-part content array — concatenate the text parts only.
|
|
20403
|
+
return content
|
|
20404
|
+
.map((part) => {
|
|
20405
|
+
if (typeof part === 'string')
|
|
20406
|
+
return part;
|
|
20407
|
+
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
|
|
20408
|
+
return part.text;
|
|
20409
|
+
}
|
|
20410
|
+
return '';
|
|
20411
|
+
})
|
|
20412
|
+
.join('');
|
|
20413
|
+
}
|
|
20414
|
+
}
|
|
20415
|
+
return '';
|
|
20416
|
+
}
|
|
20417
|
+
/**
|
|
20418
|
+
* Streaming variant of `executeChain`. Pipes the prompt into the LLM,
|
|
20419
|
+
* consumes the resulting async iterable, fires `onChunk` with each text
|
|
20420
|
+
* fragment as it arrives, and runs the supplied parser against the
|
|
20421
|
+
* fully-accumulated text on completion. Returns the parsed result.
|
|
20422
|
+
*
|
|
20423
|
+
* Why a separate function instead of an `onChunk?` flag on
|
|
20424
|
+
* `executeChain`? Two reasons:
|
|
20425
|
+
*
|
|
20426
|
+
* 1. The two paths have meaningfully different failure modes — a
|
|
20427
|
+
* half-streamed result can be salvaged with a best-effort parse;
|
|
20428
|
+
* an `invoke()` failure can't. Separate functions let each handle
|
|
20429
|
+
* its own error shape cleanly.
|
|
20430
|
+
* 2. Callers should make an explicit choice about whether they want
|
|
20431
|
+
* streaming. Adding it as an opt-in flag on `executeChain` makes
|
|
20432
|
+
* it tempting to plumb `onChunk` from random surfaces; a separate
|
|
20433
|
+
* helper makes the call site say "yes, this needs streaming."
|
|
20434
|
+
*
|
|
20435
|
+
* No automatic fallback to non-streaming `executeChain` when streaming
|
|
20436
|
+
* fails — by design. Callers that want fallback should `catch` this
|
|
20437
|
+
* function and call `executeChain` themselves. Keeps the helper focused
|
|
20438
|
+
* on the streaming path and the fallback policy explicit at the call
|
|
20439
|
+
* site (different commands may want different fallback strategies).
|
|
20440
|
+
*/
|
|
20441
|
+
async function executeChainStreaming({ llm, prompt, variables, parser, onChunk, signal, provider, endpoint, logger, tokenizer, metadata, }) {
|
|
20442
|
+
validateRequired(llm, 'llm', 'executeChainStreaming');
|
|
20443
|
+
validateRequired(prompt, 'prompt', 'executeChainStreaming');
|
|
20444
|
+
validateRequired(variables, 'variables', 'executeChainStreaming');
|
|
20445
|
+
validateRequired(parser, 'parser', 'executeChainStreaming');
|
|
20446
|
+
validateRequired(onChunk, 'onChunk', 'executeChainStreaming');
|
|
20447
|
+
if (typeof variables !== 'object' || Array.isArray(variables)) {
|
|
20448
|
+
throw new LangChainExecutionError('executeChainStreaming: Variables must be a non-array object', { variables, type: typeof variables, isArray: Array.isArray(variables) });
|
|
20449
|
+
}
|
|
20450
|
+
// Pre-flight abort check (#881 phase 3). Callers that ran the cancel
|
|
20451
|
+
// path before reaching here shouldn't pay for prompt rendering or
|
|
20452
|
+
// request setup. Match the contract `chain.stream(..., { signal })`
|
|
20453
|
+
// would have honoured — throw `LangChainCancelledError` rather than
|
|
20454
|
+
// a bare `AbortError`.
|
|
20455
|
+
if (signal?.aborted) {
|
|
20456
|
+
throw new LangChainCancelledError('executeChainStreaming: Aborted before stream opened', '');
|
|
20457
|
+
}
|
|
20458
|
+
const llmInfo = extractLlmInfo(llm);
|
|
20459
|
+
const effectiveProvider = provider || llmInfo.provider;
|
|
20460
|
+
const effectiveEndpoint = endpoint || llmInfo.endpoint;
|
|
20461
|
+
let accumulated = '';
|
|
20462
|
+
try {
|
|
20463
|
+
const renderedPrompt = await prompt.format(variables);
|
|
20464
|
+
const promptTokens = estimatePromptTokens(tokenizer, renderedPrompt);
|
|
20465
|
+
const chain = prompt.pipe(llm);
|
|
20466
|
+
const startedAt = Date.now();
|
|
20467
|
+
// Forward the signal into LangChain's RunnableConfig. The HTTP
|
|
20468
|
+
// transport (openai / anthropic / ollama clients) honours it and
|
|
20469
|
+
// tears down the connection rather than waiting for the model to
|
|
20470
|
+
// finish. The async iterator throws an AbortError that we
|
|
20471
|
+
// classify below.
|
|
20472
|
+
const stream = await chain.stream(variables, signal ? { signal } : undefined);
|
|
20473
|
+
let chunkCount = 0;
|
|
20474
|
+
for await (const messageChunk of stream) {
|
|
20475
|
+
const text = coerceChunkText(messageChunk);
|
|
20476
|
+
if (!text)
|
|
20477
|
+
continue;
|
|
20478
|
+
accumulated += text;
|
|
20479
|
+
chunkCount += 1;
|
|
20480
|
+
try {
|
|
20481
|
+
onChunk({ text, accumulated });
|
|
20482
|
+
}
|
|
20483
|
+
catch (callbackError) {
|
|
20484
|
+
// Deliberately swallow callback errors so a bad render handler
|
|
20485
|
+
// can't tank the entire LLM call. Log at verbose so users with
|
|
20486
|
+
// verbose mode on can still see what happened.
|
|
20487
|
+
logger?.verbose(`executeChainStreaming: onChunk handler threw: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
|
|
20488
|
+
}
|
|
20489
|
+
}
|
|
20490
|
+
if (!accumulated) {
|
|
20491
|
+
throw new LangChainExecutionError('executeChainStreaming: Stream completed with no text chunks', { variables, promptInputVariables: prompt.inputVariables });
|
|
20492
|
+
}
|
|
20493
|
+
const result = (await parser.invoke(accumulated));
|
|
20494
|
+
const elapsedMs = Date.now() - startedAt;
|
|
20495
|
+
logLlmCall(logger, {
|
|
20496
|
+
task: metadata?.task || 'chain-streaming',
|
|
20497
|
+
provider: effectiveProvider,
|
|
20498
|
+
parserType: parser.constructor.name,
|
|
20499
|
+
variableKeys: Object.keys(variables),
|
|
20500
|
+
promptTokens,
|
|
20501
|
+
elapsedMs,
|
|
20502
|
+
// Surfaced in observability so consumers can spot the streaming
|
|
20503
|
+
// path in their logs without correlating across tools. `chunks`
|
|
20504
|
+
// doubles as a sanity check (a streaming call that delivered 1
|
|
20505
|
+
// chunk is functionally identical to a non-streaming one).
|
|
20506
|
+
streamed: true,
|
|
20507
|
+
streamChunks: chunkCount,
|
|
20508
|
+
...metadata,
|
|
20509
|
+
});
|
|
20510
|
+
if (result === null || result === undefined) {
|
|
20511
|
+
throw new LangChainExecutionError('executeChainStreaming: Parser returned null or undefined from streamed text', {
|
|
20512
|
+
variables,
|
|
20513
|
+
promptInputVariables: prompt.inputVariables,
|
|
20514
|
+
accumulatedLength: accumulated.length,
|
|
20515
|
+
});
|
|
20516
|
+
}
|
|
20517
|
+
return result;
|
|
20518
|
+
}
|
|
20519
|
+
catch (error) {
|
|
20520
|
+
// Cancellation classifier (#881 phase 3). Three signals: an
|
|
20521
|
+
// explicitly aborted user signal (post-throw check), the
|
|
20522
|
+
// standard DOM `AbortError`, or a Node `AbortSignal` with
|
|
20523
|
+
// `signal.aborted === true` while a chain-internal error
|
|
20524
|
+
// propagates. Any of these means "user wanted out," not "the
|
|
20525
|
+
// call failed." Wrap the raw error so callers can pattern-match
|
|
20526
|
+
// on `LangChainCancelledError` and carry the partial accumulated
|
|
20527
|
+
// text in case the caller wants to salvage anything.
|
|
20528
|
+
const aborted = signal?.aborted ||
|
|
20529
|
+
(error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted')));
|
|
20530
|
+
if (aborted) {
|
|
20531
|
+
throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
|
|
20532
|
+
provider: effectiveProvider,
|
|
20533
|
+
endpoint: effectiveEndpoint,
|
|
20534
|
+
});
|
|
20535
|
+
}
|
|
20536
|
+
if (error instanceof LangChainExecutionError ||
|
|
20537
|
+
error instanceof LangChainNetworkError ||
|
|
20538
|
+
error instanceof LangChainCancelledError) {
|
|
20539
|
+
throw error;
|
|
20540
|
+
}
|
|
20541
|
+
if (error instanceof Error && isNetworkError(error)) {
|
|
20542
|
+
throw new LangChainNetworkError(error.message, effectiveEndpoint, effectiveProvider, {
|
|
20543
|
+
originalError: error.name,
|
|
20544
|
+
originalMessage: error.message,
|
|
20545
|
+
stack: error.stack,
|
|
20546
|
+
promptInputVariables: prompt.inputVariables,
|
|
20547
|
+
variableKeys: Object.keys(variables),
|
|
20548
|
+
parserType: parser.constructor.name,
|
|
20549
|
+
streamed: true,
|
|
20550
|
+
});
|
|
20551
|
+
}
|
|
20552
|
+
handleLangChainError(error, 'executeChainStreaming: Stream execution failed', {
|
|
20553
|
+
promptInputVariables: prompt.inputVariables,
|
|
20554
|
+
variableKeys: Object.keys(variables),
|
|
20555
|
+
parserType: parser.constructor.name,
|
|
20556
|
+
provider: effectiveProvider,
|
|
20557
|
+
endpoint: effectiveEndpoint,
|
|
20558
|
+
streamed: true,
|
|
20559
|
+
});
|
|
20560
|
+
}
|
|
20561
|
+
}
|
|
20562
|
+
|
|
20181
20563
|
const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
|
|
20182
20564
|
|
|
20183
20565
|
REQUIRED JSON FORMAT:
|
|
@@ -20202,7 +20584,45 @@ IMPORTANT RULES:
|
|
|
20202
20584
|
* are surfaced as `validationErrors`/`warnings` rather than driving an
|
|
20203
20585
|
* interactive retry flow — the TUI can re-invoke or let the user edit.
|
|
20204
20586
|
*/
|
|
20205
|
-
|
|
20587
|
+
/**
|
|
20588
|
+
* Fallback parser shared between the non-streaming
|
|
20589
|
+
* `executeChainWithSchema` call and the streaming path (#881 phase 2).
|
|
20590
|
+
*
|
|
20591
|
+
* Extracted from the inline `fallbackParser` option so the streaming
|
|
20592
|
+
* path can use the same lossy-but-permissive recovery for accumulated
|
|
20593
|
+
* text. Strips markdown code fences, attempts strict JSON parse, and
|
|
20594
|
+
* falls back to "first line is title, rest is body" when JSON parsing
|
|
20595
|
+
* fails entirely.
|
|
20596
|
+
*
|
|
20597
|
+
* Returned shape always satisfies the schema's structural requirements
|
|
20598
|
+
* (`title` + `body` strings) but the *content* may be the last-ditch
|
|
20599
|
+
* "Auto-generated commit" placeholder. Callers should treat this as a
|
|
20600
|
+
* best-effort salvage, not a parse confirmation.
|
|
20601
|
+
*/
|
|
20602
|
+
function salvageCommitMessageFromText(text) {
|
|
20603
|
+
try {
|
|
20604
|
+
let cleanText = text.trim();
|
|
20605
|
+
const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
20606
|
+
if (codeBlockMatch && codeBlockMatch[1]) {
|
|
20607
|
+
cleanText = codeBlockMatch[1].trim();
|
|
20608
|
+
}
|
|
20609
|
+
const parsed = JSON.parse(cleanText);
|
|
20610
|
+
if (parsed && typeof parsed === 'object' &&
|
|
20611
|
+
typeof parsed.title === 'string' &&
|
|
20612
|
+
typeof parsed.body === 'string' &&
|
|
20613
|
+
parsed.title.length > 0) {
|
|
20614
|
+
return parsed;
|
|
20615
|
+
}
|
|
20616
|
+
}
|
|
20617
|
+
catch {
|
|
20618
|
+
// fall through to line-split salvage
|
|
20619
|
+
}
|
|
20620
|
+
return {
|
|
20621
|
+
title: text.split('\n')[0] || 'Auto-generated commit',
|
|
20622
|
+
body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
|
|
20623
|
+
};
|
|
20624
|
+
}
|
|
20625
|
+
async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), onStreamChunk, signal, }) {
|
|
20206
20626
|
const config = loadConfig(argv);
|
|
20207
20627
|
const key = getApiKeyForModel(config);
|
|
20208
20628
|
const { provider } = getModelAndProviderFromConfig(config);
|
|
@@ -20345,42 +20765,117 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20345
20765
|
tokenizer,
|
|
20346
20766
|
maxTokens: config.service.tokenLimit || 2048,
|
|
20347
20767
|
});
|
|
20348
|
-
|
|
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
|
-
|
|
20374
|
-
|
|
20375
|
-
|
|
20376
|
-
|
|
20768
|
+
// Streaming path (#881 phase 2). Active when the caller supplied
|
|
20769
|
+
// an `onStreamChunk` AND the config opted in. Only the FIRST
|
|
20770
|
+
// attempt streams; the commitlint-retry attempt (attempt === 2)
|
|
20771
|
+
// and the existing executeChainWithSchema retry loop run
|
|
20772
|
+
// non-streaming so we keep the schema-validated retry as the
|
|
20773
|
+
// backstop when the streamed text can't be salvaged.
|
|
20774
|
+
const streamingEnabled = Boolean(onStreamChunk && config.service.streaming?.enabled);
|
|
20775
|
+
const shouldStreamThisAttempt = streamingEnabled && attempt === 1;
|
|
20776
|
+
let commitMsg;
|
|
20777
|
+
if (shouldStreamThisAttempt && onStreamChunk) {
|
|
20778
|
+
// The streaming chain bypasses the schema parser during the
|
|
20779
|
+
// stream itself (no streaming-aware JSON parser today) and
|
|
20780
|
+
// delivers the raw accumulated text to a no-op `parser.invoke`.
|
|
20781
|
+
// We then salvage the structured result via the same lossy
|
|
20782
|
+
// recovery the non-streaming fallbackParser uses. If the
|
|
20783
|
+
// salvager produces a plausible draft, we use it. Otherwise we
|
|
20784
|
+
// fall through to executeChainWithSchema below for a real
|
|
20785
|
+
// schema-validated retry — paying for a second LLM call only
|
|
20786
|
+
// on the edge case where the streamed output is unsalvageable.
|
|
20787
|
+
const streamingParser = createSchemaParser(schema, llm);
|
|
20788
|
+
let salvaged;
|
|
20789
|
+
try {
|
|
20790
|
+
// `executeChainStreaming` runs the parser on the accumulated
|
|
20791
|
+
// text at completion. StructuredOutputParser will throw when
|
|
20792
|
+
// the model produced unparseable JSON — we catch that below
|
|
20793
|
+
// and salvage manually. The happy-path zod-validated object
|
|
20794
|
+
// becomes our commitMsg.
|
|
20795
|
+
commitMsg = await executeChainStreaming({
|
|
20796
|
+
llm,
|
|
20797
|
+
prompt,
|
|
20798
|
+
variables: budgetedPrompt.variables,
|
|
20799
|
+
parser: streamingParser,
|
|
20800
|
+
onChunk: ({ text, accumulated }) => {
|
|
20801
|
+
onStreamChunk(text, accumulated);
|
|
20802
|
+
},
|
|
20803
|
+
signal,
|
|
20804
|
+
logger,
|
|
20805
|
+
tokenizer,
|
|
20806
|
+
metadata: {
|
|
20807
|
+
task: useConventional ? 'commit-message-conventional' : 'commit-message',
|
|
20808
|
+
command: 'commit-draft',
|
|
20809
|
+
provider,
|
|
20810
|
+
model: String(model),
|
|
20811
|
+
},
|
|
20812
|
+
});
|
|
20813
|
+
}
|
|
20814
|
+
catch (streamErr) {
|
|
20815
|
+
// User-initiated cancel (#881 phase 3). Bail out of the
|
|
20816
|
+
// entire attempt loop and let the caller distinguish
|
|
20817
|
+
// "cancelled" from "failed" in the status line. We do NOT
|
|
20818
|
+
// fall through to the non-streaming retry on cancel — the
|
|
20819
|
+
// user explicitly asked to stop, kicking off a fresh
|
|
20820
|
+
// unstreamable LLM call would defy that intent.
|
|
20821
|
+
if (streamErr instanceof LangChainCancelledError) {
|
|
20822
|
+
return {
|
|
20823
|
+
ok: false,
|
|
20824
|
+
draft: streamErr.accumulated || '',
|
|
20825
|
+
warnings,
|
|
20826
|
+
validationErrors: [],
|
|
20827
|
+
cancelled: true,
|
|
20828
|
+
};
|
|
20377
20829
|
}
|
|
20378
|
-
|
|
20379
|
-
|
|
20380
|
-
|
|
20381
|
-
|
|
20382
|
-
|
|
20383
|
-
|
|
20830
|
+
// Streamed accumulated text didn't parse cleanly. Try the
|
|
20831
|
+
// lossy salvager on whatever we have; if that produces a
|
|
20832
|
+
// non-placeholder title, accept it. Otherwise fall through
|
|
20833
|
+
// to the non-streaming path which can retry with a fresh
|
|
20834
|
+
// LLM call.
|
|
20835
|
+
logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
|
|
20836
|
+
salvaged = undefined;
|
|
20837
|
+
}
|
|
20838
|
+
// Type-narrow: commitMsg is set inside try{}, but TS doesn't
|
|
20839
|
+
// see that across the catch. Re-init through the salvage path
|
|
20840
|
+
// if streaming threw.
|
|
20841
|
+
if (salvaged) {
|
|
20842
|
+
commitMsg = salvaged;
|
|
20843
|
+
}
|
|
20844
|
+
else if (!(commitMsg)) {
|
|
20845
|
+
// Streaming threw; do the standard non-streaming flow to
|
|
20846
|
+
// recover. This is the trade-off documented in the issue —
|
|
20847
|
+
// streaming gives us a preview but the validated result still
|
|
20848
|
+
// comes from the schema-aware retry path when streaming fails.
|
|
20849
|
+
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20850
|
+
logger,
|
|
20851
|
+
tokenizer,
|
|
20852
|
+
metadata: {
|
|
20853
|
+
task: useConventional ? 'commit-message-conventional' : 'commit-message',
|
|
20854
|
+
command: 'commit-draft',
|
|
20855
|
+
provider,
|
|
20856
|
+
model: String(model),
|
|
20857
|
+
},
|
|
20858
|
+
retryOptions: { maxAttempts: maxParsingAttempts },
|
|
20859
|
+
fallbackParser: salvageCommitMessageFromText,
|
|
20860
|
+
});
|
|
20861
|
+
}
|
|
20862
|
+
}
|
|
20863
|
+
else {
|
|
20864
|
+
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20865
|
+
logger,
|
|
20866
|
+
tokenizer,
|
|
20867
|
+
metadata: {
|
|
20868
|
+
task: useConventional ? 'commit-message-conventional' : 'commit-message',
|
|
20869
|
+
command: 'commit-draft',
|
|
20870
|
+
provider,
|
|
20871
|
+
model: String(model),
|
|
20872
|
+
},
|
|
20873
|
+
retryOptions: {
|
|
20874
|
+
maxAttempts: maxParsingAttempts,
|
|
20875
|
+
},
|
|
20876
|
+
fallbackParser: salvageCommitMessageFromText,
|
|
20877
|
+
});
|
|
20878
|
+
}
|
|
20384
20879
|
const ticketId = extractTicketIdFromBranchName(branchName);
|
|
20385
20880
|
const fullMessage = formatCommitMessage(commitMsg, {
|
|
20386
20881
|
append: argv.append,
|
|
@@ -20478,8 +20973,26 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
20478
20973
|
const argv = createCommitWorkflowArgv('commit');
|
|
20479
20974
|
const logger = new Logger({ silent: true });
|
|
20480
20975
|
try {
|
|
20481
|
-
const result = await generateCommitDraft({
|
|
20976
|
+
const result = await generateCommitDraft({
|
|
20977
|
+
git,
|
|
20978
|
+
argv,
|
|
20979
|
+
logger,
|
|
20980
|
+
onStreamChunk: input.onStreamChunk,
|
|
20981
|
+
signal: input.signal,
|
|
20982
|
+
});
|
|
20482
20983
|
const draft = result.draft.trim();
|
|
20984
|
+
// Cancel path (#881 phase 3). Reported separately from success
|
|
20985
|
+
// / failure so the runtime can render a neutral "cancelled"
|
|
20986
|
+
// status line instead of an error.
|
|
20987
|
+
if (result.cancelled) {
|
|
20988
|
+
return {
|
|
20989
|
+
ok: false,
|
|
20990
|
+
message: 'AI draft cancelled.',
|
|
20991
|
+
details: [],
|
|
20992
|
+
draft: '',
|
|
20993
|
+
cancelled: true,
|
|
20994
|
+
};
|
|
20995
|
+
}
|
|
20483
20996
|
if (result.ok && draft) {
|
|
20484
20997
|
return {
|
|
20485
20998
|
ok: true,
|
|
@@ -20568,6 +21081,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
20568
21081
|
ok: true,
|
|
20569
21082
|
plan: result.plan,
|
|
20570
21083
|
planContext: result.context,
|
|
21084
|
+
fallback: result.fallback,
|
|
20571
21085
|
};
|
|
20572
21086
|
}
|
|
20573
21087
|
catch (error) {
|
|
@@ -20612,6 +21126,7 @@ async function runCommitSplitApplyWorkflow(input) {
|
|
|
20612
21126
|
git,
|
|
20613
21127
|
logger,
|
|
20614
21128
|
noVerify: input.noVerify || false,
|
|
21129
|
+
fallback: input.fallback,
|
|
20615
21130
|
});
|
|
20616
21131
|
return {
|
|
20617
21132
|
ok: true,
|
|
@@ -20622,6 +21137,7 @@ async function runCommitSplitApplyWorkflow(input) {
|
|
|
20622
21137
|
// I/O AND inaccurate when partial-apply landed fewer commits
|
|
20623
21138
|
// than the plan had groups.
|
|
20624
21139
|
commitHashes: applied.commitHashes,
|
|
21140
|
+
fallback: applied.fallback,
|
|
20625
21141
|
};
|
|
20626
21142
|
}
|
|
20627
21143
|
catch (error) {
|
|
@@ -21085,6 +21601,37 @@ function getLogInkWorkflowActions() {
|
|
|
21085
21601
|
kind: 'normal',
|
|
21086
21602
|
requiresConfirmation: false,
|
|
21087
21603
|
},
|
|
21604
|
+
// Per-view variants of fetch / pull / push that act on the
|
|
21605
|
+
// cursored branch instead of the current one. Empty `key` keeps
|
|
21606
|
+
// them palette-discoverable without registering a global hotkey —
|
|
21607
|
+
// inkInput.ts dispatches them contextually when the user presses
|
|
21608
|
+
// F / U / P while the branches sidebar is focused. Outside that
|
|
21609
|
+
// context, the F / U / P keys still fire the global *-current-*
|
|
21610
|
+
// / fetch-remotes variants above.
|
|
21611
|
+
{
|
|
21612
|
+
id: 'fetch-selected-branch',
|
|
21613
|
+
key: '',
|
|
21614
|
+
label: 'Fetch selected branch',
|
|
21615
|
+
description: 'Run `git fetch <remote> <branch>` for the cursored branch in the branches view / sidebar.',
|
|
21616
|
+
kind: 'normal',
|
|
21617
|
+
requiresConfirmation: false,
|
|
21618
|
+
},
|
|
21619
|
+
{
|
|
21620
|
+
id: 'pull-selected-branch',
|
|
21621
|
+
key: '',
|
|
21622
|
+
label: 'Pull selected branch',
|
|
21623
|
+
description: 'Pull the cursored branch in the branches view / sidebar. Falls back to a fast-forward-only refspec fetch when the branch is not currently checked out; refuses non-FF.',
|
|
21624
|
+
kind: 'normal',
|
|
21625
|
+
requiresConfirmation: false,
|
|
21626
|
+
},
|
|
21627
|
+
{
|
|
21628
|
+
id: 'push-selected-branch',
|
|
21629
|
+
key: '',
|
|
21630
|
+
label: 'Push selected branch',
|
|
21631
|
+
description: 'Run `git push <remote> <branch>` for the cursored branch in the branches view / sidebar.',
|
|
21632
|
+
kind: 'normal',
|
|
21633
|
+
requiresConfirmation: false,
|
|
21634
|
+
},
|
|
21088
21635
|
{
|
|
21089
21636
|
// Per-view-only — the inkInput handler scopes this to the tags
|
|
21090
21637
|
// surface so we don't expose `R` as a remote-delete from elsewhere.
|
|
@@ -22025,8 +22572,22 @@ function getLogInkFooterHints(options) {
|
|
|
22025
22572
|
// "enter open" hint that drills into the dedicated view.
|
|
22026
22573
|
const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
|
|
22027
22574
|
if (itemsPresent && options.sidebarTab === 'branches') {
|
|
22575
|
+
// P / U / F fire the global pull-current-branch, push-current-branch,
|
|
22576
|
+
// fetch-remotes workflows — already implemented, just not visible in
|
|
22577
|
+
// the footer before. Surfacing them here matters because the user's
|
|
22578
|
+
// attention is on a branch when the branches sidebar is focused;
|
|
22579
|
+
// pull / push / fetch are the next obvious actions.
|
|
22580
|
+
//
|
|
22581
|
+
// Note: `U` and `P` currently operate on the CURRENT branch, not the
|
|
22582
|
+
// cursored one. Task #5 will extend them to act on the cursored row;
|
|
22583
|
+
// until then the labels read as "current-branch ops" by virtue of
|
|
22584
|
+
// matching the workflow descriptions.
|
|
22028
22585
|
return {
|
|
22029
|
-
contextual: [
|
|
22586
|
+
contextual: [
|
|
22587
|
+
'↑/↓ branches', '←/→ tab', 'enter checkout',
|
|
22588
|
+
'F fetch', 'U pull', 'P push',
|
|
22589
|
+
'D delete', 'R rename', 'u upstream',
|
|
22590
|
+
],
|
|
22030
22591
|
global: NORMAL_GLOBAL_HINTS,
|
|
22031
22592
|
};
|
|
22032
22593
|
}
|
|
@@ -22721,10 +23282,17 @@ function withPoppedView(state) {
|
|
|
22721
23282
|
* in a clean slate — the mental equivalent of a fresh `coco ui`
|
|
22722
23283
|
* launched against the submodule's working dir.
|
|
22723
23284
|
*
|
|
22724
|
-
*
|
|
22725
|
-
*
|
|
22726
|
-
*
|
|
22727
|
-
*
|
|
23285
|
+
* Sidebar tab + branch / tag sort are also captured into the return
|
|
23286
|
+
* snapshot (#995) so popping back restores the parent's choices
|
|
23287
|
+
* instead of letting the submodule's tab/sort bleed across the
|
|
23288
|
+
* boundary. The values on the *new* frame are left as-is (carried
|
|
23289
|
+
* over from the parent) — the load effect in app.ts re-reads
|
|
23290
|
+
* persistence keyed on the submodule's workdir and dispatches a
|
|
23291
|
+
* restore if the user has a submodule-specific saved preference.
|
|
23292
|
+
*
|
|
23293
|
+
* Other preferences (palette recents, inspector tab, diff view mode)
|
|
23294
|
+
* stay global by design — the user's preference shouldn't reset when
|
|
23295
|
+
* they cross a submodule boundary.
|
|
22728
23296
|
*
|
|
22729
23297
|
* Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
|
|
22730
23298
|
* outside the reducer in `app.ts`'s parallel ref structure — this
|
|
@@ -22741,6 +23309,10 @@ function withPushedRepoFrame(state, payload) {
|
|
|
22741
23309
|
selectedFileIndex: state.selectedFileIndex,
|
|
22742
23310
|
selectedSubmoduleIndex: state.selectedSubmoduleIndex,
|
|
22743
23311
|
filter: state.filter,
|
|
23312
|
+
sidebarTab: state.sidebarTab,
|
|
23313
|
+
userSidebarTab: state.userSidebarTab,
|
|
23314
|
+
branchSort: state.branchSort,
|
|
23315
|
+
tagSort: state.tagSort,
|
|
22744
23316
|
},
|
|
22745
23317
|
};
|
|
22746
23318
|
return {
|
|
@@ -22793,6 +23365,15 @@ function withPoppedRepoFrame(state) {
|
|
|
22793
23365
|
filter: ret.filter,
|
|
22794
23366
|
filterMode: false,
|
|
22795
23367
|
pendingCommitFocused: false,
|
|
23368
|
+
// #995 — restore sidebar tab + sort preferences from the captured
|
|
23369
|
+
// parentReturn. Without this, the submodule's tab / sort choice
|
|
23370
|
+
// bleeds back into the parent after pop: the user picks 'tags' in
|
|
23371
|
+
// a vendored submodule, pops back to the parent, and finds the
|
|
23372
|
+
// parent's previously-selected 'branches' tab quietly replaced.
|
|
23373
|
+
sidebarTab: ret.sidebarTab,
|
|
23374
|
+
userSidebarTab: ret.userSidebarTab,
|
|
23375
|
+
branchSort: ret.branchSort,
|
|
23376
|
+
tagSort: ret.tagSort,
|
|
22796
23377
|
pendingKey: undefined,
|
|
22797
23378
|
pendingConfirmationId: undefined,
|
|
22798
23379
|
pendingConfirmationPayload: undefined,
|
|
@@ -23568,6 +24149,17 @@ function applyLogInkAction(state, action) {
|
|
|
23568
24149
|
statusLoading: !action.value ? undefined : (action.loading ? true : undefined),
|
|
23569
24150
|
pendingKey: undefined,
|
|
23570
24151
|
};
|
|
24152
|
+
case 'setPendingPullRequestBodyDraft':
|
|
24153
|
+
// PR-body draft tracker (#881 phase 4). Set true while
|
|
24154
|
+
// `startCreatePullRequest` is awaiting the changelog-based
|
|
24155
|
+
// body generation; gates the Esc cancel binding in the input
|
|
24156
|
+
// handler so pressing Esc during the wait skips opening the
|
|
24157
|
+
// follow-up prompt instead of falling through to global Esc.
|
|
24158
|
+
return {
|
|
24159
|
+
...state,
|
|
24160
|
+
pendingPullRequestBodyDraft: action.value || undefined,
|
|
24161
|
+
pendingKey: undefined,
|
|
24162
|
+
};
|
|
23571
24163
|
case 'setWorkflowAction':
|
|
23572
24164
|
return {
|
|
23573
24165
|
...state,
|
|
@@ -23811,6 +24403,7 @@ function applyLogInkAction(state, action) {
|
|
|
23811
24403
|
plan: action.plan,
|
|
23812
24404
|
planContext: action.planContext,
|
|
23813
24405
|
scrollOffset: 0,
|
|
24406
|
+
fallback: action.fallback,
|
|
23814
24407
|
},
|
|
23815
24408
|
pendingKey: undefined,
|
|
23816
24409
|
};
|
|
@@ -24587,6 +25180,36 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24587
25180
|
}
|
|
24588
25181
|
return [];
|
|
24589
25182
|
}
|
|
25183
|
+
// Cancel in-flight AI commit draft (#881 phase 3). When the compose
|
|
25184
|
+
// surface is mid-stream (loading === true), Esc aborts the LLM call
|
|
25185
|
+
// and the runtime handler cleans up (clear loading, clear preview,
|
|
25186
|
+
// status line shows "AI draft cancelled."). Sits above the editing
|
|
25187
|
+
// / view handlers so the cancel keystroke can't fall through to
|
|
25188
|
+
// "leave compose" or anything else.
|
|
25189
|
+
//
|
|
25190
|
+
// Loading and editing are mutually exclusive in practice (the user
|
|
25191
|
+
// can't type while the AI is generating), but the order here makes
|
|
25192
|
+
// the precedence explicit if that ever changes.
|
|
25193
|
+
if (state.activeView === 'compose' && state.commitCompose.loading && key.escape) {
|
|
25194
|
+
return [{ type: 'cancelAiCommitDraft' }];
|
|
25195
|
+
}
|
|
25196
|
+
// Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
|
|
25197
|
+
// kicks off a changelog-based draft that runs for 5-15 seconds
|
|
25198
|
+
// before the input prompt opens. While the draft is pending, Esc
|
|
25199
|
+
// tells the runtime to skip the prompt and surface a "cancelled"
|
|
25200
|
+
// status. Unlike the compose cancel above, this is a *soft* cancel
|
|
25201
|
+
// — the background LLM call still completes, but its result is
|
|
25202
|
+
// discarded. Acceptable trade-off for now; deeper signal threading
|
|
25203
|
+
// through `changelogHandler` lands in a follow-up if real cancel
|
|
25204
|
+
// becomes a request.
|
|
25205
|
+
//
|
|
25206
|
+
// Sits unconditionally on the global Esc check (no `activeView`
|
|
25207
|
+
// gate) because the draft can be initiated from any view via the
|
|
25208
|
+
// palette `C` binding; Esc must work wherever the user is when
|
|
25209
|
+
// they decide to bail.
|
|
25210
|
+
if (state.pendingPullRequestBodyDraft && key.escape) {
|
|
25211
|
+
return [{ type: 'cancelPullRequestBodyDraft' }];
|
|
25212
|
+
}
|
|
24590
25213
|
if (state.commitCompose.editing) {
|
|
24591
25214
|
if (key.escape) {
|
|
24592
25215
|
return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
|
|
@@ -26455,6 +27078,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26455
27078
|
events.push({ type: 'createManualCommit' });
|
|
26456
27079
|
return events;
|
|
26457
27080
|
}
|
|
27081
|
+
// Context-sensitive per-branch variants of F / U / P. When the
|
|
27082
|
+
// user has the branches sidebar / view focused with at least one
|
|
27083
|
+
// branch, F / U / P should act on the cursored row, not on the
|
|
27084
|
+
// current branch. This intercept fires BEFORE the generic
|
|
27085
|
+
// workflow-by-key lookup below so the global *-current-branch
|
|
27086
|
+
// variants don't shadow the contextual ones.
|
|
27087
|
+
//
|
|
27088
|
+
// Outside the branches context, the generic lookup runs and the
|
|
27089
|
+
// F / U / P keys hit the global `fetch-remotes` / `pull-current-branch`
|
|
27090
|
+
// / `push-current-branch` workflows as before.
|
|
27091
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
27092
|
+
if (inputValue === 'F') {
|
|
27093
|
+
return [{ type: 'runWorkflowAction', id: 'fetch-selected-branch' }];
|
|
27094
|
+
}
|
|
27095
|
+
if (inputValue === 'U') {
|
|
27096
|
+
return [{ type: 'runWorkflowAction', id: 'pull-selected-branch' }];
|
|
27097
|
+
}
|
|
27098
|
+
if (inputValue === 'P') {
|
|
27099
|
+
return [{ type: 'runWorkflowAction', id: 'push-selected-branch' }];
|
|
27100
|
+
}
|
|
27101
|
+
}
|
|
26458
27102
|
const workflowAction = getLogInkWorkflowActionByKey(inputValue);
|
|
26459
27103
|
if (workflowAction?.requiresConfirmation) {
|
|
26460
27104
|
return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
|
|
@@ -26582,17 +27226,24 @@ function formatRemainingWorktreeHint(unstaged, untracked) {
|
|
|
26582
27226
|
*
|
|
26583
27227
|
* When the worktree is clean post-apply:
|
|
26584
27228
|
* "Created N commits — press gh to view them in history. Worktree is clean."
|
|
27229
|
+
*
|
|
27230
|
+
* When `fallback` is set, the planner exhausted its retry budget and
|
|
27231
|
+
* the apply landed the single-group fallback plan instead of a real
|
|
27232
|
+
* multi-group split. Prefix the message so the user knows the result
|
|
27233
|
+
* isn't a true LLM split — they may want to re-roll with a different
|
|
27234
|
+
* model, or accept the combined commit as-is.
|
|
26585
27235
|
*/
|
|
26586
|
-
function formatSplitApplySuccess(commitCount, unstaged, untracked) {
|
|
27236
|
+
function formatSplitApplySuccess(commitCount, unstaged, untracked, fallback) {
|
|
26587
27237
|
const created = commitCount === 1
|
|
26588
27238
|
? 'Created 1 commit'
|
|
26589
27239
|
: `Created ${commitCount} commits`;
|
|
26590
27240
|
const navCue = `${created} — press gh to view them in history.`;
|
|
26591
27241
|
const remainingHint = formatRemainingWorktreeHint(unstaged, untracked);
|
|
26592
|
-
|
|
26593
|
-
|
|
27242
|
+
const tail = remainingHint ? ` ${remainingHint}` : ' Worktree is clean.';
|
|
27243
|
+
if (fallback) {
|
|
27244
|
+
return `Split planner fallback applied (combined commit) — ${fallback.reason}. ${navCue}${tail}`;
|
|
26594
27245
|
}
|
|
26595
|
-
return `${navCue}
|
|
27246
|
+
return `${navCue}${tail}`;
|
|
26596
27247
|
}
|
|
26597
27248
|
|
|
26598
27249
|
/**
|
|
@@ -27557,6 +28208,106 @@ function pushCurrentBranch(git) {
|
|
|
27557
28208
|
function setUpstream(git, localBranch, upstreamBranch) {
|
|
27558
28209
|
return runAction$5(() => git.raw(['branch', '--set-upstream-to', upstreamBranch, localBranch]), `Set ${localBranch} upstream to ${upstreamBranch}`);
|
|
27559
28210
|
}
|
|
28211
|
+
/**
|
|
28212
|
+
* Push an arbitrary local branch (need not be the current branch) to
|
|
28213
|
+
* its remote. Refuses when the branch has no upstream and no remote
|
|
28214
|
+
* defaulting is configured — that branch needs a `git push -u …` from
|
|
28215
|
+
* the shell first.
|
|
28216
|
+
*
|
|
28217
|
+
* Pairs with `pushCurrentBranch` (no-arg variant); the workstation
|
|
28218
|
+
* dispatcher picks one or the other based on where the cursor is.
|
|
28219
|
+
*/
|
|
28220
|
+
function pushBranch(git, branch) {
|
|
28221
|
+
if (branch.type !== 'local') {
|
|
28222
|
+
return Promise.resolve({
|
|
28223
|
+
ok: false,
|
|
28224
|
+
message: 'Only local branches can be pushed.',
|
|
28225
|
+
});
|
|
28226
|
+
}
|
|
28227
|
+
if (!branch.upstream || !branch.remote) {
|
|
28228
|
+
return Promise.resolve({
|
|
28229
|
+
ok: false,
|
|
28230
|
+
message: `${branch.shortName} has no upstream — checkout the branch and run \`git push -u <remote> ${branch.shortName}\` first.`,
|
|
28231
|
+
});
|
|
28232
|
+
}
|
|
28233
|
+
return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
|
|
28234
|
+
}
|
|
28235
|
+
/**
|
|
28236
|
+
* Fetch the cursored branch's upstream from its remote. Side-effect
|
|
28237
|
+
* free on the working tree — just updates the remote-tracking ref.
|
|
28238
|
+
* Works for any branch with an upstream regardless of checkout state.
|
|
28239
|
+
*
|
|
28240
|
+
* Falls back to a clean error when the branch has no upstream
|
|
28241
|
+
* configured (`git fetch <remote> <name>` would assume an unrelated
|
|
28242
|
+
* default refspec and surprise the user).
|
|
28243
|
+
*/
|
|
28244
|
+
function fetchBranch(git, branch) {
|
|
28245
|
+
if (branch.type !== 'local') {
|
|
28246
|
+
return Promise.resolve({
|
|
28247
|
+
ok: false,
|
|
28248
|
+
message: 'Only local branches can be fetched per-branch — use F to fetch all remotes.',
|
|
28249
|
+
});
|
|
28250
|
+
}
|
|
28251
|
+
if (!branch.upstream || !branch.remote) {
|
|
28252
|
+
return Promise.resolve({
|
|
28253
|
+
ok: false,
|
|
28254
|
+
message: `${branch.shortName} has no upstream — nothing to fetch.`,
|
|
28255
|
+
});
|
|
28256
|
+
}
|
|
28257
|
+
// `branch.upstream` is the short form (e.g. `origin/main`); the
|
|
28258
|
+
// ref name after the remote prefix is what fetch wants as the
|
|
28259
|
+
// refspec source. For a remote `origin` and upstream `origin/main`
|
|
28260
|
+
// we run `git fetch origin main`.
|
|
28261
|
+
const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
|
|
28262
|
+
? branch.upstream.slice(branch.remote.length + 1)
|
|
28263
|
+
: branch.upstream;
|
|
28264
|
+
return runAction$5(() => git.raw(['fetch', branch.remote, upstreamRef]), `Fetched ${branch.upstream}`);
|
|
28265
|
+
}
|
|
28266
|
+
/**
|
|
28267
|
+
* Pull the cursored branch. Branches into two paths based on whether
|
|
28268
|
+
* the branch is currently checked out:
|
|
28269
|
+
*
|
|
28270
|
+
* - **Current branch**: defer to `pullCurrentBranch` (standard
|
|
28271
|
+
* `git pull --ff-only`).
|
|
28272
|
+
* - **Non-current branch**: use the refspec form
|
|
28273
|
+
* `git fetch <remote> <branch>:<branch>` which advances the local
|
|
28274
|
+
* ref to match the remote ref ONLY if the update is fast-forward.
|
|
28275
|
+
* Returns non-zero on non-FF without touching the working tree.
|
|
28276
|
+
* Diverged branches need a checkout + `pull --rebase` from the
|
|
28277
|
+
* user; we refuse rather than try to do that for them.
|
|
28278
|
+
*
|
|
28279
|
+
* `currentBranchName` lets the dispatcher compare without re-querying
|
|
28280
|
+
* git — it already has the value in `context.branches.currentBranch`.
|
|
28281
|
+
*/
|
|
28282
|
+
function pullBranch(git, branch, currentBranchName) {
|
|
28283
|
+
if (branch.type !== 'local') {
|
|
28284
|
+
return Promise.resolve({
|
|
28285
|
+
ok: false,
|
|
28286
|
+
message: 'Only local branches can be pulled.',
|
|
28287
|
+
});
|
|
28288
|
+
}
|
|
28289
|
+
if (!branch.upstream || !branch.remote) {
|
|
28290
|
+
return Promise.resolve({
|
|
28291
|
+
ok: false,
|
|
28292
|
+
message: `${branch.shortName} has no upstream — nothing to pull.`,
|
|
28293
|
+
});
|
|
28294
|
+
}
|
|
28295
|
+
// Current branch — defer to the in-place workflow.
|
|
28296
|
+
if (branch.shortName === currentBranchName) {
|
|
28297
|
+
return pullCurrentBranch(git);
|
|
28298
|
+
}
|
|
28299
|
+
// Non-current branch — refspec-based fast-forward refusing non-FF.
|
|
28300
|
+
// `branch.upstream` is `<remote>/<ref>`; strip the remote prefix to
|
|
28301
|
+
// get the upstream ref name to fetch.
|
|
28302
|
+
const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
|
|
28303
|
+
? branch.upstream.slice(branch.remote.length + 1)
|
|
28304
|
+
: branch.upstream;
|
|
28305
|
+
return runAction$5(() => git.raw([
|
|
28306
|
+
'fetch',
|
|
28307
|
+
branch.remote,
|
|
28308
|
+
`${upstreamRef}:${branch.shortName}`,
|
|
28309
|
+
]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
|
|
28310
|
+
}
|
|
27560
28311
|
|
|
27561
28312
|
async function runAction$4(action, successMessage) {
|
|
27562
28313
|
try {
|
|
@@ -29080,29 +29831,81 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
29080
29831
|
parts.push(`↓${branch.behind}`);
|
|
29081
29832
|
return `${parts.join(' ')} ${branch.upstream}`;
|
|
29082
29833
|
}
|
|
29083
|
-
|
|
29084
|
-
|
|
29085
|
-
|
|
29086
|
-
|
|
29087
|
-
|
|
29088
|
-
|
|
29089
|
-
|
|
29090
|
-
|
|
29091
|
-
|
|
29092
|
-
|
|
29093
|
-
|
|
29094
|
-
|
|
29834
|
+
function formatUpstreamAheadBanner(branch, options = {}) {
|
|
29835
|
+
if (!branch?.upstream || branch.behind <= 0) {
|
|
29836
|
+
return undefined;
|
|
29837
|
+
}
|
|
29838
|
+
const sep = options.ascii ? '.' : '·';
|
|
29839
|
+
if (branch.ahead > 0) {
|
|
29840
|
+
// Diverged — local has work too, fast-forward pull is impossible.
|
|
29841
|
+
// Suggest pull --rebase as the cleaner-history default; users who
|
|
29842
|
+
// prefer merge can do that themselves.
|
|
29843
|
+
const symbols = options.ascii
|
|
29844
|
+
? `+${branch.ahead} -${branch.behind}`
|
|
29845
|
+
: `↑${branch.ahead} ↓${branch.behind}`;
|
|
29846
|
+
return `${symbols} diverged from ${branch.upstream} ${sep} F fetch ${sep} U pull --rebase`;
|
|
29847
|
+
}
|
|
29848
|
+
// Behind-only — fast-forward pull works.
|
|
29849
|
+
const arrow = options.ascii ? 'v' : '↓';
|
|
29850
|
+
const noun = branch.behind === 1 ? 'commit' : 'commits';
|
|
29851
|
+
return `${arrow} ${branch.behind} ${noun} behind ${branch.upstream} ${sep} F fetch ${sep} U pull`;
|
|
29852
|
+
}
|
|
29095
29853
|
function branchRowMarker(branch, options = {}) {
|
|
29096
|
-
if (branch.current)
|
|
29097
|
-
return '*';
|
|
29098
|
-
|
|
29099
|
-
|
|
29854
|
+
if (branch.current) {
|
|
29855
|
+
return { glyph: '*', kind: 'head' };
|
|
29856
|
+
}
|
|
29857
|
+
if (!branch.upstream) {
|
|
29858
|
+
return { glyph: options.ascii ? '?' : '◌', kind: 'no-upstream' };
|
|
29859
|
+
}
|
|
29100
29860
|
const ahead = branch.ahead ?? 0;
|
|
29101
29861
|
const behind = branch.behind ?? 0;
|
|
29102
29862
|
if (ahead === 0 && behind === 0) {
|
|
29103
|
-
return options.ascii ? '=' : '≡';
|
|
29863
|
+
return { glyph: options.ascii ? '=' : '≡', kind: 'synced' };
|
|
29864
|
+
}
|
|
29865
|
+
if (ahead > 0 && behind > 0) {
|
|
29866
|
+
return { glyph: options.ascii ? '~' : '⇅', kind: 'diverged' };
|
|
29867
|
+
}
|
|
29868
|
+
if (behind > 0) {
|
|
29869
|
+
return { glyph: options.ascii ? 'v' : '↓', kind: 'behind' };
|
|
29870
|
+
}
|
|
29871
|
+
// ahead > 0 (the only remaining case after the guards above)
|
|
29872
|
+
return { glyph: options.ascii ? '^' : '↑', kind: 'ahead' };
|
|
29873
|
+
}
|
|
29874
|
+
/**
|
|
29875
|
+
* Theme-aware colour picker for a `BranchRowMarker.kind`.
|
|
29876
|
+
*
|
|
29877
|
+
* Reuses the existing chip / banner colour semantic so the workstation
|
|
29878
|
+
* speaks one visual language across history (chips, "behind upstream"
|
|
29879
|
+
* banner) and the branches list:
|
|
29880
|
+
*
|
|
29881
|
+
* - `head` → success green (matches HEAD chip)
|
|
29882
|
+
* - `behind` → warning yellow (matches "behind upstream" banner)
|
|
29883
|
+
* - `diverged` → warning yellow (same: action needed inbound)
|
|
29884
|
+
* - `ahead` → info blue (you have work to push)
|
|
29885
|
+
* - `synced` → undefined (neutral; inherit row's existing dim)
|
|
29886
|
+
* - `no-upstream` → undefined (neutral; same)
|
|
29887
|
+
*
|
|
29888
|
+
* Returns `undefined` under `noColor` / `ascii` for the muted cases so
|
|
29889
|
+
* the row renderer skips the colour wrap entirely; the glyph alone
|
|
29890
|
+
* carries the meaning.
|
|
29891
|
+
*/
|
|
29892
|
+
function getBranchRowMarkerColor(kind, theme) {
|
|
29893
|
+
if (theme.noColor)
|
|
29894
|
+
return undefined;
|
|
29895
|
+
switch (kind) {
|
|
29896
|
+
case 'head':
|
|
29897
|
+
return theme.colors.success;
|
|
29898
|
+
case 'behind':
|
|
29899
|
+
case 'diverged':
|
|
29900
|
+
return theme.colors.warning;
|
|
29901
|
+
case 'ahead':
|
|
29902
|
+
return theme.colors.info;
|
|
29903
|
+
case 'synced':
|
|
29904
|
+
case 'no-upstream':
|
|
29905
|
+
return undefined;
|
|
29906
|
+
default:
|
|
29907
|
+
return undefined;
|
|
29104
29908
|
}
|
|
29105
|
-
return options.ascii ? '~' : '↕';
|
|
29106
29909
|
}
|
|
29107
29910
|
/**
|
|
29108
29911
|
* Compact, human-friendly relative timestamp for the branch row.
|
|
@@ -29710,7 +30513,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
29710
30513
|
];
|
|
29711
30514
|
return [
|
|
29712
30515
|
...headerRows,
|
|
29713
|
-
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
|
|
30516
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii }).glyph} ${branch.shortName}`, 'tab-branches', visibleListCount),
|
|
29714
30517
|
];
|
|
29715
30518
|
}
|
|
29716
30519
|
if (tab === 'tags') {
|
|
@@ -30173,21 +30976,22 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
30173
30976
|
const isSelected = index === selected;
|
|
30174
30977
|
const cursor = isSelected ? '>' : ' ';
|
|
30175
30978
|
const marker = branchRowMarker(branch, { ascii: theme.ascii });
|
|
30979
|
+
const markerColor = getBranchRowMarkerColor(marker.kind, theme);
|
|
30176
30980
|
const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
|
|
30177
30981
|
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
30178
30982
|
// Split the row into spans so the timestamp stays dim even on the
|
|
30179
|
-
// currently-selected (bold) row
|
|
30180
|
-
//
|
|
30181
|
-
// right-padded so the divergence column stays aligned across rows.
|
|
30983
|
+
// currently-selected (bold) row, and the sync-state marker keeps
|
|
30984
|
+
// its own colour even when the surrounding row text is dimmed.
|
|
30182
30985
|
const namePadded = truncateCells(branch.shortName, nameColWidth).padEnd(nameColWidth);
|
|
30183
30986
|
const timestampPadded = lastTouched.padEnd(8);
|
|
30184
30987
|
const lineDim = !isSelected && !branch.current;
|
|
30185
|
-
const
|
|
30988
|
+
const cursorAndPad = `${cursor} `;
|
|
30989
|
+
const trailingName = ` ${namePadded} `;
|
|
30186
30990
|
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
30187
30991
|
// Truncate the assembled line to the actual panel width so a
|
|
30188
30992
|
// narrow inspector / sidebar focus doesn't push branch rows
|
|
30189
30993
|
// onto a second visual line (#830).
|
|
30190
|
-
const fullText = `${
|
|
30994
|
+
const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
|
|
30191
30995
|
const truncated = truncateCells(fullText, Math.max(20, width - 4));
|
|
30192
30996
|
// If truncation chopped into the timestamp/divergence portion,
|
|
30193
30997
|
// fall back to a single Text to keep the visible width honest.
|
|
@@ -30202,7 +31006,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
30202
31006
|
key: `branch-${index}`,
|
|
30203
31007
|
bold: isSelected,
|
|
30204
31008
|
dimColor: lineDim,
|
|
30205
|
-
},
|
|
31009
|
+
}, cursorAndPad,
|
|
31010
|
+
// The marker carries the sync-state colour; an explicit
|
|
31011
|
+
// `dimColor: false` on this span keeps the colour bright even
|
|
31012
|
+
// when the surrounding row is dim (other branches in the list
|
|
31013
|
+
// dim out under the existing `lineDim` rule). The synced /
|
|
31014
|
+
// no-upstream kinds return undefined from
|
|
31015
|
+
// `getBranchRowMarkerColor`, so those markers inherit the
|
|
31016
|
+
// row's dim and read as quiet chrome.
|
|
31017
|
+
h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
30206
31018
|
});
|
|
30207
31019
|
return h(Box, {
|
|
30208
31020
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -30329,6 +31141,97 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
|
|
|
30329
31141
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
|
|
30330
31142
|
}
|
|
30331
31143
|
|
|
31144
|
+
/**
|
|
31145
|
+
* Streaming-preview helper (#881 phase 2). Turns the raw accumulated
|
|
31146
|
+
* text from an in-flight LLM stream into the last N visual lines that
|
|
31147
|
+
* fit a given panel width, plus a flag telling the renderer whether
|
|
31148
|
+
* earlier content was elided.
|
|
31149
|
+
*
|
|
31150
|
+
* Why a chrome helper instead of inlining the math in the compose
|
|
31151
|
+
* surface: the same shape is going to be reused by PR-body and review
|
|
31152
|
+
* streaming once those surfaces opt in. The visual line math (wrap to
|
|
31153
|
+
* width, count from the bottom, mark truncation) doesn't belong on the
|
|
31154
|
+
* surface itself.
|
|
31155
|
+
*
|
|
31156
|
+
* No JSX / no Ink here — chrome modules stay framework-agnostic and
|
|
31157
|
+
* return data the surface can hand to its own `h(Text, ...)` calls.
|
|
31158
|
+
*/
|
|
31159
|
+
/**
|
|
31160
|
+
* Default last-N visible visual lines. Tuned for compose where the
|
|
31161
|
+
* panel already shows summary + body + loading line, so the preview
|
|
31162
|
+
* can't take more vertical space without pushing the state-line off
|
|
31163
|
+
* the bottom of short terminals. 6 lines is roughly two short
|
|
31164
|
+
* commit-body paragraphs — enough to feel like content is flowing,
|
|
31165
|
+
* not so much that the user loses sight of the surrounding chrome.
|
|
31166
|
+
*/
|
|
31167
|
+
const DEFAULT_STREAMING_PREVIEW_LINES = 6;
|
|
31168
|
+
/**
|
|
31169
|
+
* Marker prefixed to the first visible line when earlier content was
|
|
31170
|
+
* elided. Chrome theme picks ASCII vs Unicode at render time; this
|
|
31171
|
+
* module returns both so surfaces don't need to import the theme.
|
|
31172
|
+
*/
|
|
31173
|
+
const STREAMING_PREVIEW_TRUNCATE_GLYPH = '…';
|
|
31174
|
+
const STREAMING_PREVIEW_TRUNCATE_ASCII = '...';
|
|
31175
|
+
/**
|
|
31176
|
+
* Compute the visible preview window for a streaming buffer.
|
|
31177
|
+
*
|
|
31178
|
+
* The buffer is split on newlines (preserving blank lines so paragraph
|
|
31179
|
+
* spacing stays visible), each source line is hard-wrapped to `width`,
|
|
31180
|
+
* and the trailing `maxLines` wrapped lines are returned. When the
|
|
31181
|
+
* total wrapped line count exceeds `maxLines`, `truncated` is true so
|
|
31182
|
+
* the renderer can prefix the first line with an ellipsis marker.
|
|
31183
|
+
*
|
|
31184
|
+
* Whitespace-only / empty input returns `{ lines: [], truncated: false }`
|
|
31185
|
+
* so renderers can branch on `lines.length === 0` to skip rendering
|
|
31186
|
+
* entirely during the brief window between dispatching `setLoading`
|
|
31187
|
+
* and the first chunk arriving.
|
|
31188
|
+
*
|
|
31189
|
+
* Width math mirrors the compose surface's body wrap (`width - 6` for
|
|
31190
|
+
* border + paddingX + 2-space indent budget); callers pass the width
|
|
31191
|
+
* they intend to use and this helper assumes it's the wrap budget,
|
|
31192
|
+
* not the panel width.
|
|
31193
|
+
*/
|
|
31194
|
+
function formatStreamingPreview(accumulated, width, maxLines = DEFAULT_STREAMING_PREVIEW_LINES) {
|
|
31195
|
+
if (!accumulated) {
|
|
31196
|
+
return { lines: [], truncated: false };
|
|
31197
|
+
}
|
|
31198
|
+
const trimmed = accumulated.replace(/\s+$/u, '');
|
|
31199
|
+
if (!trimmed) {
|
|
31200
|
+
return { lines: [], truncated: false };
|
|
31201
|
+
}
|
|
31202
|
+
// Wrap each source line. Empty source lines must survive the wrap so
|
|
31203
|
+
// a stream like "A\n\nB" reads as two paragraphs separated by a blank
|
|
31204
|
+
// row rather than collapsing into "A B".
|
|
31205
|
+
const wrapWidth = Math.max(8, width);
|
|
31206
|
+
const wrapped = [];
|
|
31207
|
+
for (const line of trimmed.split('\n')) {
|
|
31208
|
+
if (line === '') {
|
|
31209
|
+
wrapped.push('');
|
|
31210
|
+
continue;
|
|
31211
|
+
}
|
|
31212
|
+
for (const segment of wrapCells(line, wrapWidth)) {
|
|
31213
|
+
wrapped.push(segment);
|
|
31214
|
+
}
|
|
31215
|
+
}
|
|
31216
|
+
const budget = Math.max(1, maxLines);
|
|
31217
|
+
if (wrapped.length <= budget) {
|
|
31218
|
+
return { lines: wrapped, truncated: false };
|
|
31219
|
+
}
|
|
31220
|
+
return {
|
|
31221
|
+
lines: wrapped.slice(wrapped.length - budget),
|
|
31222
|
+
truncated: true,
|
|
31223
|
+
};
|
|
31224
|
+
}
|
|
31225
|
+
/**
|
|
31226
|
+
* Resolve the truncation marker for the current theme. Pure helper so
|
|
31227
|
+
* the surface can render a single-character glyph in colour terminals
|
|
31228
|
+
* and the ASCII fallback when `theme.ascii` is on. Centralised here so
|
|
31229
|
+
* future surfaces opting into streaming use the same glyph.
|
|
31230
|
+
*/
|
|
31231
|
+
function streamingPreviewTruncateMarker(ascii) {
|
|
31232
|
+
return ascii ? STREAMING_PREVIEW_TRUNCATE_ASCII : STREAMING_PREVIEW_TRUNCATE_GLYPH;
|
|
31233
|
+
}
|
|
31234
|
+
|
|
30332
31235
|
/**
|
|
30333
31236
|
* Compose surface — the in-TUI commit-message composer. Combines a
|
|
30334
31237
|
* summary line, a body field, and a state-line footer; an inline
|
|
@@ -30338,6 +31241,33 @@ function renderChangelogSurface(h, components, state, _context, _contextStatus,
|
|
|
30338
31241
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
30339
31242
|
* of #890. No behavior change.
|
|
30340
31243
|
*/
|
|
31244
|
+
/**
|
|
31245
|
+
* Render the streaming-preview block — the trailing lines of the
|
|
31246
|
+
* in-flight LLM stream that sit below the loading spinner. Pure
|
|
31247
|
+
* formatting; the wrap math + truncation flag live in the
|
|
31248
|
+
* `streamingPreview` chrome helper so other surfaces (PR body,
|
|
31249
|
+
* review) can reuse them later.
|
|
31250
|
+
*
|
|
31251
|
+
* Returns an empty array when no preview text is present (the loader
|
|
31252
|
+
* just shows the spinner) so the caller's spread doesn't insert blank
|
|
31253
|
+
* rows that would shift the state-line.
|
|
31254
|
+
*/
|
|
31255
|
+
function renderStreamingPreviewLines(h, components, preview, width, theme) {
|
|
31256
|
+
const { Text } = components;
|
|
31257
|
+
const view = formatStreamingPreview(preview, width);
|
|
31258
|
+
if (view.lines.length === 0)
|
|
31259
|
+
return [];
|
|
31260
|
+
const marker = view.truncated ? streamingPreviewTruncateMarker(theme.ascii) : '';
|
|
31261
|
+
return view.lines.map((line, index) => {
|
|
31262
|
+
// Prefix the first line with the truncation marker when earlier
|
|
31263
|
+
// content was elided. Subsequent lines render unprefixed.
|
|
31264
|
+
const prefix = index === 0 && marker ? `${marker} ` : ' ';
|
|
31265
|
+
return h(Text, {
|
|
31266
|
+
key: `compose-stream-${index}`,
|
|
31267
|
+
dimColor: true,
|
|
31268
|
+
}, `${prefix}${line}`);
|
|
31269
|
+
});
|
|
31270
|
+
}
|
|
30341
31271
|
function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
|
|
30342
31272
|
const { Box, Text } = components;
|
|
30343
31273
|
const compose = state.commitCompose;
|
|
@@ -30361,9 +31291,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
30361
31291
|
: ['<empty>'];
|
|
30362
31292
|
const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
|
|
30363
31293
|
);
|
|
31294
|
+
// State-line cycles through three modes (#881 phase 3 added the
|
|
31295
|
+
// loading variant): editing copy when the user is typing, cancel
|
|
31296
|
+
// hint when an AI draft is generating, default guidance otherwise.
|
|
31297
|
+
// The cancel hint also covers the streaming preview window — same
|
|
31298
|
+
// keystroke (Esc) aborts whether or not the preview is visible.
|
|
30364
31299
|
const stateLine = compose.editing
|
|
30365
31300
|
? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
|
|
30366
|
-
:
|
|
31301
|
+
: compose.loading
|
|
31302
|
+
? 'Generating AI draft — press Esc to cancel.'
|
|
31303
|
+
: 'Press e to edit, c to commit, I for AI draft, esc to leave.';
|
|
30367
31304
|
const hasStagedFiles = (worktree?.files || [])
|
|
30368
31305
|
.some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
|
|
30369
31306
|
// Staged file list is rendered in the right Worktree panel
|
|
@@ -30410,6 +31347,13 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
30410
31347
|
}, theme.ascii
|
|
30411
31348
|
? `[${pickSpinnerFrame(spinnerFrame).replace(/[^a-zA-Z0-9 ]/g, '.')}] Generating AI commit draft (this can take a moment)`
|
|
30412
31349
|
: `${pickSpinnerFrame(spinnerFrame)} Generating AI commit draft… (this can take a moment)`),
|
|
31350
|
+
// Streaming preview (#881 phase 2). Renders the trailing visual
|
|
31351
|
+
// lines of the in-flight LLM stream below the loader so the user
|
|
31352
|
+
// sees content building up instead of an opaque spinner. Empty
|
|
31353
|
+
// before the first chunk arrives; the preview helper returns an
|
|
31354
|
+
// empty `lines` array in that window so we skip the block
|
|
31355
|
+
// entirely.
|
|
31356
|
+
...renderStreamingPreviewLines(h, components, compose.streamingPreview, bodyTextWidth, theme),
|
|
30413
31357
|
]
|
|
30414
31358
|
: []), ...(compose.message ? [h(Text, undefined, ''), h(Text, { key: 'compose-msg' }, truncateCells(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
|
|
30415
31359
|
key: `compose-detail-${index}`,
|
|
@@ -32214,6 +33158,25 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
32214
33158
|
paddingX: 1,
|
|
32215
33159
|
width,
|
|
32216
33160
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${title} | ${graphMode} | ${loadState}`)),
|
|
33161
|
+
// Upstream-ahead banner. Surfaces "the remote has work you don't"
|
|
33162
|
+
// for the current branch — distinct from the chip work in 0.52.0
|
|
33163
|
+
// which colours remote refs IN the row set. On a behind branch the
|
|
33164
|
+
// upstream commits aren't reachable from local HEAD, so the chips
|
|
33165
|
+
// alone can't signal "fetch / pull needed." This single line does.
|
|
33166
|
+
//
|
|
33167
|
+
// Two wording variants (behind-only vs diverged) live in the
|
|
33168
|
+
// helper; render is identical aside from the formatted string.
|
|
33169
|
+
// Warning yellow = same semantic as the remote-tracking chip kind.
|
|
33170
|
+
...((() => {
|
|
33171
|
+
const currentBranchRef = context.branches?.localBranches.find((branch) => branch.current);
|
|
33172
|
+
const banner = formatUpstreamAheadBanner(currentBranchRef, { ascii: theme.ascii });
|
|
33173
|
+
if (!banner)
|
|
33174
|
+
return [];
|
|
33175
|
+
return [h(Text, {
|
|
33176
|
+
key: 'upstream-ahead-banner',
|
|
33177
|
+
color: theme.noColor ? undefined : theme.colors.warning,
|
|
33178
|
+
}, banner)];
|
|
33179
|
+
})()),
|
|
32217
33180
|
// Server-side filter indicator (#776). Only rendered when the user
|
|
32218
33181
|
// has an active path:/author: prefix; clears when they Ctrl+U.
|
|
32219
33182
|
...(state.historyFetchArgs
|
|
@@ -35264,9 +36227,18 @@ function LogInkApp(deps) {
|
|
|
35264
36227
|
// Wrappers that delegate to the active frame's runtime entry so the
|
|
35265
36228
|
// existing call sites stay byte-identical. Support both function-
|
|
35266
36229
|
// updater and value-updater forms (the codebase uses both).
|
|
35267
|
-
|
|
36230
|
+
//
|
|
36231
|
+
// `targetDepth` (#994) routes the write to a specific frame instead
|
|
36232
|
+
// of the currently-active one. Loaders that capture the depth at
|
|
36233
|
+
// issue-time and pass it here are robust against frame-stack
|
|
36234
|
+
// mutations (push / pop) that happen while the load is in flight —
|
|
36235
|
+
// the write lands on the frame that issued it, or silently drops
|
|
36236
|
+
// if that frame has been popped (`updateRepoFrameRuntime` no-ops on
|
|
36237
|
+
// out-of-range indices). Without the tag, an in-flight refresh on
|
|
36238
|
+
// the parent would clobber a freshly-pushed submodule frame.
|
|
36239
|
+
const setContext = React.useCallback((arg, targetDepth) => {
|
|
35268
36240
|
setRuntimes((prev) => {
|
|
35269
|
-
const depth = prev.length - 1;
|
|
36241
|
+
const depth = targetDepth ?? prev.length - 1;
|
|
35270
36242
|
if (depth < 0)
|
|
35271
36243
|
return prev;
|
|
35272
36244
|
return updateRepoFrameRuntime(prev, depth, (frame) => ({
|
|
@@ -35277,9 +36249,9 @@ function LogInkApp(deps) {
|
|
|
35277
36249
|
}));
|
|
35278
36250
|
});
|
|
35279
36251
|
}, []);
|
|
35280
|
-
const setContextStatus = React.useCallback((arg) => {
|
|
36252
|
+
const setContextStatus = React.useCallback((arg, targetDepth) => {
|
|
35281
36253
|
setRuntimes((prev) => {
|
|
35282
|
-
const depth = prev.length - 1;
|
|
36254
|
+
const depth = targetDepth ?? prev.length - 1;
|
|
35283
36255
|
if (depth < 0)
|
|
35284
36256
|
return prev;
|
|
35285
36257
|
return updateRepoFrameRuntime(prev, depth, (frame) => ({
|
|
@@ -35605,28 +36577,39 @@ function LogInkApp(deps) {
|
|
|
35605
36577
|
// (stale-while-revalidate) and quietly swap it in once the new fetch
|
|
35606
36578
|
// resolves — avoids the every-second flicker the watcher would
|
|
35607
36579
|
// otherwise produce on busy repos.
|
|
36580
|
+
//
|
|
36581
|
+
// #994 — capture the depth this refresh was issued from BEFORE
|
|
36582
|
+
// the await. The callback closure also captured `git` from the
|
|
36583
|
+
// same render, so they're consistent: when the user drills into
|
|
36584
|
+
// a submodule mid-await, the resolved data still lands on the
|
|
36585
|
+
// parent frame (the one whose `git` was used for the fetch),
|
|
36586
|
+
// not on the freshly-pushed submodule frame.
|
|
36587
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35608
36588
|
if (!options.silent) {
|
|
35609
36589
|
dispatch({ type: 'setStatus', value: 'refreshing repository context' });
|
|
35610
|
-
setContextStatus(createLogInkContextStatus('loading'));
|
|
36590
|
+
setContextStatus(createLogInkContextStatus('loading'), issuedAtDepth);
|
|
35611
36591
|
}
|
|
35612
36592
|
const next = await loadLogInkContext(git);
|
|
35613
|
-
setContext(next);
|
|
35614
|
-
setContextStatus(createLogInkContextStatus('ready'));
|
|
36593
|
+
setContext(next, issuedAtDepth);
|
|
36594
|
+
setContextStatus(createLogInkContextStatus('ready'), issuedAtDepth);
|
|
35615
36595
|
if (!options.silent) {
|
|
35616
36596
|
dispatch({ type: 'setStatus', value: 'repository context refreshed' });
|
|
35617
36597
|
}
|
|
35618
|
-
}, [dispatch, git]);
|
|
36598
|
+
}, [dispatch, git, runtimes.length, setContext, setContextStatus]);
|
|
35619
36599
|
const refreshWorktreeContext = React.useCallback(async (options = {}) => {
|
|
36600
|
+
// #994 — same frame-tagging as refreshContext above. Worktree
|
|
36601
|
+
// loads are usually fast but still race-prone on slow disks.
|
|
36602
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35620
36603
|
if (!options.silent) {
|
|
35621
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
|
|
36604
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'), issuedAtDepth);
|
|
35622
36605
|
}
|
|
35623
36606
|
const worktree = await safe(getWorktreeOverview(git));
|
|
35624
36607
|
setContext((current) => ({
|
|
35625
36608
|
...current,
|
|
35626
36609
|
worktree,
|
|
35627
|
-
}));
|
|
35628
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
|
|
35629
|
-
}, [git]);
|
|
36610
|
+
}), issuedAtDepth);
|
|
36611
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
|
|
36612
|
+
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
35630
36613
|
// Live refresh: watch .git metadata + the working tree root and reload
|
|
35631
36614
|
// context when something changes outside the TUI (editor save, external
|
|
35632
36615
|
// git commands, branch switch in another terminal). Best-effort — the
|
|
@@ -35851,6 +36834,11 @@ function LogInkApp(deps) {
|
|
|
35851
36834
|
const contextStatusRef = React.useRef(contextStatus);
|
|
35852
36835
|
contextStatusRef.current = contextStatus;
|
|
35853
36836
|
React.useEffect(() => {
|
|
36837
|
+
// #994 — capture the depth this boot load is being issued for.
|
|
36838
|
+
// The git instance in the closure is bound to this frame; tagged
|
|
36839
|
+
// writes ensure resolved values land on the correct runtime entry
|
|
36840
|
+
// even if a subsequent push/pop changes the active frame mid-load.
|
|
36841
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35854
36842
|
let active = true;
|
|
35855
36843
|
loadLogInkContextEntries(git).forEach(({ key, load }) => {
|
|
35856
36844
|
if (contextStatusRef.current[key] === 'ready')
|
|
@@ -35862,14 +36850,14 @@ function LogInkApp(deps) {
|
|
|
35862
36850
|
setContext((current) => ({
|
|
35863
36851
|
...current,
|
|
35864
36852
|
[key]: value,
|
|
35865
|
-
}));
|
|
35866
|
-
setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'));
|
|
36853
|
+
}), issuedAtDepth);
|
|
36854
|
+
setContextStatus((current) => updateLogInkContextStatus(current, key, 'ready'), issuedAtDepth);
|
|
35867
36855
|
});
|
|
35868
36856
|
});
|
|
35869
36857
|
return () => {
|
|
35870
36858
|
active = false;
|
|
35871
36859
|
};
|
|
35872
|
-
}, [git]);
|
|
36860
|
+
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
35873
36861
|
// Lazy-load the full pullRequest overview (#808). Only fires when
|
|
35874
36862
|
// the user actually navigates to the PR view, and only when we
|
|
35875
36863
|
// don't already have data (so a workflow-triggered refresh that
|
|
@@ -35883,21 +36871,22 @@ function LogInkApp(deps) {
|
|
|
35883
36871
|
return;
|
|
35884
36872
|
if (context.pullRequest)
|
|
35885
36873
|
return;
|
|
36874
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35886
36875
|
let active = true;
|
|
35887
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
|
|
36876
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'), issuedAtDepth);
|
|
35888
36877
|
void safe(getPullRequestOverview(git)).then((value) => {
|
|
35889
36878
|
if (!active)
|
|
35890
36879
|
return;
|
|
35891
36880
|
setContext((current) => ({
|
|
35892
36881
|
...current,
|
|
35893
36882
|
pullRequest: value,
|
|
35894
|
-
}));
|
|
35895
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
|
|
36883
|
+
}), issuedAtDepth);
|
|
36884
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'), issuedAtDepth);
|
|
35896
36885
|
});
|
|
35897
36886
|
return () => {
|
|
35898
36887
|
active = false;
|
|
35899
36888
|
};
|
|
35900
|
-
}, [git, state.activeView, context.pullRequest]);
|
|
36889
|
+
}, [git, runtimes.length, state.activeView, context.pullRequest, setContext, setContextStatus]);
|
|
35901
36890
|
// Lazy-load the issue triage list (#882 phase 3, filter-aware
|
|
35902
36891
|
// since phase 6). Fires on entry to the view AND on filter
|
|
35903
36892
|
// preset changes (`f` cycles the preset; the dep on
|
|
@@ -35909,8 +36898,9 @@ function LogInkApp(deps) {
|
|
|
35909
36898
|
return;
|
|
35910
36899
|
if (context.issueList)
|
|
35911
36900
|
return;
|
|
36901
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35912
36902
|
let active = true;
|
|
35913
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
|
|
36903
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'), issuedAtDepth);
|
|
35914
36904
|
const filter = issueFilterForPreset(state.selectedIssueFilter);
|
|
35915
36905
|
void safe(getIssueList(git, filter)).then((value) => {
|
|
35916
36906
|
if (!active)
|
|
@@ -35918,13 +36908,21 @@ function LogInkApp(deps) {
|
|
|
35918
36908
|
setContext((current) => ({
|
|
35919
36909
|
...current,
|
|
35920
36910
|
issueList: value,
|
|
35921
|
-
}));
|
|
35922
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
|
|
36911
|
+
}), issuedAtDepth);
|
|
36912
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'), issuedAtDepth);
|
|
35923
36913
|
});
|
|
35924
36914
|
return () => {
|
|
35925
36915
|
active = false;
|
|
35926
36916
|
};
|
|
35927
|
-
}, [
|
|
36917
|
+
}, [
|
|
36918
|
+
git,
|
|
36919
|
+
runtimes.length,
|
|
36920
|
+
state.activeView,
|
|
36921
|
+
context.issueList,
|
|
36922
|
+
state.selectedIssueFilter,
|
|
36923
|
+
setContext,
|
|
36924
|
+
setContextStatus,
|
|
36925
|
+
]);
|
|
35928
36926
|
// Filter cycling: when the preset changes, drop the cached list
|
|
35929
36927
|
// so the effect above re-fires with the new filter. Done as a
|
|
35930
36928
|
// separate effect (rather than folded into the cycle reducer)
|
|
@@ -35948,8 +36946,9 @@ function LogInkApp(deps) {
|
|
|
35948
36946
|
return;
|
|
35949
36947
|
if (context.pullRequestList)
|
|
35950
36948
|
return;
|
|
36949
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35951
36950
|
let active = true;
|
|
35952
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
|
|
36951
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'), issuedAtDepth);
|
|
35953
36952
|
const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
|
|
35954
36953
|
void safe(getPullRequestList(git, filter)).then((value) => {
|
|
35955
36954
|
if (!active)
|
|
@@ -35957,13 +36956,21 @@ function LogInkApp(deps) {
|
|
|
35957
36956
|
setContext((current) => ({
|
|
35958
36957
|
...current,
|
|
35959
36958
|
pullRequestList: value,
|
|
35960
|
-
}));
|
|
35961
|
-
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
|
|
36959
|
+
}), issuedAtDepth);
|
|
36960
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'), issuedAtDepth);
|
|
35962
36961
|
});
|
|
35963
36962
|
return () => {
|
|
35964
36963
|
active = false;
|
|
35965
36964
|
};
|
|
35966
|
-
}, [
|
|
36965
|
+
}, [
|
|
36966
|
+
git,
|
|
36967
|
+
runtimes.length,
|
|
36968
|
+
state.activeView,
|
|
36969
|
+
context.pullRequestList,
|
|
36970
|
+
state.selectedPullRequestFilter,
|
|
36971
|
+
setContext,
|
|
36972
|
+
setContextStatus,
|
|
36973
|
+
]);
|
|
35967
36974
|
React.useEffect(() => {
|
|
35968
36975
|
if (state.activeView !== 'pull-request-triage')
|
|
35969
36976
|
return;
|
|
@@ -35993,6 +37000,7 @@ function LogInkApp(deps) {
|
|
|
35993
37000
|
return;
|
|
35994
37001
|
if (context.issueDetailByNumber?.has(cursored.number))
|
|
35995
37002
|
return;
|
|
37003
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
35996
37004
|
let active = true;
|
|
35997
37005
|
const timer = setTimeout(async () => {
|
|
35998
37006
|
const result = await getIssueDetail(cursored.number);
|
|
@@ -36001,17 +37009,19 @@ function LogInkApp(deps) {
|
|
|
36001
37009
|
setContext((current) => ({
|
|
36002
37010
|
...current,
|
|
36003
37011
|
issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
|
|
36004
|
-
}));
|
|
37012
|
+
}), issuedAtDepth);
|
|
36005
37013
|
}, DETAIL_HYDRATION_DELAY_MS);
|
|
36006
37014
|
return () => {
|
|
36007
37015
|
active = false;
|
|
36008
37016
|
clearTimeout(timer);
|
|
36009
37017
|
};
|
|
36010
37018
|
}, [
|
|
37019
|
+
runtimes.length,
|
|
36011
37020
|
state.activeView,
|
|
36012
37021
|
state.selectedIssueIndex,
|
|
36013
37022
|
filteredIssueList,
|
|
36014
37023
|
context.issueDetailByNumber,
|
|
37024
|
+
setContext,
|
|
36015
37025
|
]);
|
|
36016
37026
|
React.useEffect(() => {
|
|
36017
37027
|
if (state.activeView !== 'pull-request-triage')
|
|
@@ -36021,6 +37031,7 @@ function LogInkApp(deps) {
|
|
|
36021
37031
|
return;
|
|
36022
37032
|
if (context.pullRequestDetailByNumber?.has(cursored.number))
|
|
36023
37033
|
return;
|
|
37034
|
+
const issuedAtDepth = runtimes.length - 1;
|
|
36024
37035
|
let active = true;
|
|
36025
37036
|
const timer = setTimeout(async () => {
|
|
36026
37037
|
const result = await getPullRequestDetail(cursored.number);
|
|
@@ -36029,17 +37040,19 @@ function LogInkApp(deps) {
|
|
|
36029
37040
|
setContext((current) => ({
|
|
36030
37041
|
...current,
|
|
36031
37042
|
pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
|
|
36032
|
-
}));
|
|
37043
|
+
}), issuedAtDepth);
|
|
36033
37044
|
}, DETAIL_HYDRATION_DELAY_MS);
|
|
36034
37045
|
return () => {
|
|
36035
37046
|
active = false;
|
|
36036
37047
|
clearTimeout(timer);
|
|
36037
37048
|
};
|
|
36038
37049
|
}, [
|
|
37050
|
+
runtimes.length,
|
|
36039
37051
|
state.activeView,
|
|
36040
37052
|
state.selectedPullRequestTriageIndex,
|
|
36041
37053
|
filteredPullRequestTriageList,
|
|
36042
37054
|
context.pullRequestDetailByNumber,
|
|
37055
|
+
setContext,
|
|
36043
37056
|
]);
|
|
36044
37057
|
React.useEffect(() => {
|
|
36045
37058
|
let active = true;
|
|
@@ -36300,21 +37313,96 @@ function LogInkApp(deps) {
|
|
|
36300
37313
|
state.commitCompose.body,
|
|
36301
37314
|
state.commitCompose.summary,
|
|
36302
37315
|
]);
|
|
37316
|
+
// AbortController for the in-flight AI draft (#881 phase 3). Kept in
|
|
37317
|
+
// a ref rather than state because cancel is a side-effect: the input
|
|
37318
|
+
// handler reads `controllerRef.current?.abort()` synchronously when
|
|
37319
|
+
// Esc fires during a loading draft. Storing it in state would force
|
|
37320
|
+
// a re-render on every set, and React doesn't need to know — only
|
|
37321
|
+
// the imperative cancel path does. Cleared after each call settles
|
|
37322
|
+
// so a stale controller can't cancel a future draft.
|
|
37323
|
+
const aiDraftAbortRef = React.useRef(null);
|
|
36303
37324
|
const runAiCommitDraft = React.useCallback(async () => {
|
|
37325
|
+
// Tear down any controller from a previous draft (defensive — a
|
|
37326
|
+
// settled call should have cleared it in the finally block, but
|
|
37327
|
+
// double-running would otherwise leave the first orphaned).
|
|
37328
|
+
aiDraftAbortRef.current?.abort();
|
|
37329
|
+
const controller = new AbortController();
|
|
37330
|
+
aiDraftAbortRef.current = controller;
|
|
36304
37331
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
|
|
36305
37332
|
dispatch({ type: 'setStatus', value: 'generating AI commit draft', loading: true });
|
|
36306
|
-
|
|
36307
|
-
|
|
36308
|
-
|
|
36309
|
-
|
|
36310
|
-
|
|
37333
|
+
// Streaming preview (#881 phase 2). The workflow forwards this to
|
|
37334
|
+
// `generateCommitDraft`, which only actually streams when the
|
|
37335
|
+
// user opted in via `service.streaming.enabled`. The callback
|
|
37336
|
+
// updates `commitCompose.streamingPreview` so the compose surface
|
|
37337
|
+
// renders a live last-N-lines preview below the loader. The
|
|
37338
|
+
// reducer clears `streamingPreview` whenever loading flips off
|
|
37339
|
+
// (success or failure), so we don't need an explicit teardown
|
|
37340
|
+
// dispatch here.
|
|
37341
|
+
try {
|
|
37342
|
+
const result = await runCommitDraftWorkflow({
|
|
37343
|
+
git,
|
|
37344
|
+
signal: controller.signal,
|
|
37345
|
+
onStreamChunk: (_text, accumulated) => {
|
|
37346
|
+
// Dispatch the full accumulated text — the preview chrome
|
|
37347
|
+
// helper does the last-N-lines slicing at render time, so
|
|
37348
|
+
// re-doing the slice here would be wasted work. Per-chunk
|
|
37349
|
+
// dispatches are cheap; React batches them and Ink redraws
|
|
37350
|
+
// at its own frame cadence.
|
|
37351
|
+
dispatch({
|
|
37352
|
+
type: 'commitCompose',
|
|
37353
|
+
action: { type: 'setStreamingPreview', value: accumulated },
|
|
37354
|
+
});
|
|
37355
|
+
},
|
|
37356
|
+
});
|
|
37357
|
+
// Cancel path (#881 phase 3). User pressed Esc during the
|
|
37358
|
+
// stream; reducer drops loading + preview, status line shows
|
|
37359
|
+
// a neutral "cancelled" message. Skip the result / failure
|
|
37360
|
+
// dispatches because the user already knows what happened.
|
|
37361
|
+
if (result.cancelled) {
|
|
37362
|
+
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37363
|
+
dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
|
|
37364
|
+
return;
|
|
37365
|
+
}
|
|
37366
|
+
if (result.ok && result.draft) {
|
|
37367
|
+
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
|
|
37368
|
+
dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
|
|
37369
|
+
return;
|
|
37370
|
+
}
|
|
37371
|
+
dispatch({
|
|
37372
|
+
type: 'commitCompose',
|
|
37373
|
+
action: { type: 'setResult', message: result.message, details: result.details },
|
|
37374
|
+
});
|
|
37375
|
+
dispatch({ type: 'setStatus', value: result.message });
|
|
36311
37376
|
}
|
|
36312
|
-
|
|
36313
|
-
|
|
36314
|
-
|
|
36315
|
-
|
|
36316
|
-
|
|
36317
|
-
|
|
37377
|
+
finally {
|
|
37378
|
+
// Clear the ref only if it still points at OUR controller — a
|
|
37379
|
+
// rapid second invocation could have already replaced it, in
|
|
37380
|
+
// which case the new controller is the one that owns cancel
|
|
37381
|
+
// duty now.
|
|
37382
|
+
if (aiDraftAbortRef.current === controller) {
|
|
37383
|
+
aiDraftAbortRef.current = null;
|
|
37384
|
+
}
|
|
37385
|
+
}
|
|
37386
|
+
}, [dispatch, git]);
|
|
37387
|
+
/**
|
|
37388
|
+
* Cancel an in-flight AI draft (#881 phase 3). Called by the input
|
|
37389
|
+
* handler when the user presses Esc while `commitCompose.loading`
|
|
37390
|
+
* is true. Idempotent — calling without an active controller is a
|
|
37391
|
+
* no-op rather than an error so the keystroke handler can fire
|
|
37392
|
+
* unconditionally during the loading window.
|
|
37393
|
+
*
|
|
37394
|
+
* `controller.abort()` propagates through
|
|
37395
|
+
* `executeChainStreaming`, which throws `LangChainCancelledError`,
|
|
37396
|
+
* which becomes `cancelled: true` on the workflow result. The
|
|
37397
|
+
* runAiCommitDraft promise's finally block clears the ref. The
|
|
37398
|
+
* resulting cleanup dispatches (clearing loading + status) happen
|
|
37399
|
+
* back in `runAiCommitDraft`, not here, so this function stays
|
|
37400
|
+
* pure-imperative and the React state updates flow through a
|
|
37401
|
+
* single code path.
|
|
37402
|
+
*/
|
|
37403
|
+
const cancelAiCommitDraft = React.useCallback(() => {
|
|
37404
|
+
aiDraftAbortRef.current?.abort();
|
|
37405
|
+
}, []);
|
|
36318
37406
|
// `C` keystroke handler — start the create-pull-request flow. Resolves
|
|
36319
37407
|
// the head + base branches from the live context, runs
|
|
36320
37408
|
// `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
|
|
@@ -36328,6 +37416,19 @@ function LogInkApp(deps) {
|
|
|
36328
37416
|
// missing) we surface the failure on the status line and skip the
|
|
36329
37417
|
// prompt entirely — better than opening a prompt the user can't
|
|
36330
37418
|
// actually submit successfully.
|
|
37419
|
+
// Soft-cancel handle for the PR body draft (#881 phase 4). A mutable
|
|
37420
|
+
// ref rather than state because the cancel decision needs to be
|
|
37421
|
+
// visible synchronously inside the async workflow without forcing
|
|
37422
|
+
// re-renders. Owned by the in-flight invocation: the cancel callback
|
|
37423
|
+
// mutates `.cancelled` on the live ref; the workflow checks it after
|
|
37424
|
+
// `await` resolves and decides whether to open the follow-up prompt.
|
|
37425
|
+
//
|
|
37426
|
+
// The LLM call itself keeps running (no AbortSignal threaded through
|
|
37427
|
+
// `changelogHandler` today). The user-visible outcome — "PR draft
|
|
37428
|
+
// cancelled, no prompt opens" — is identical to a hard cancel, at
|
|
37429
|
+
// the cost of paying for the in-flight tokens. Deeper threading
|
|
37430
|
+
// lands in a follow-up if hard cancel becomes a request.
|
|
37431
|
+
const pullRequestBodyCancelRef = React.useRef(null);
|
|
36331
37432
|
const startCreatePullRequest = React.useCallback(async () => {
|
|
36332
37433
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
36333
37434
|
if (!head) {
|
|
@@ -36356,32 +37457,61 @@ function LogInkApp(deps) {
|
|
|
36356
37457
|
});
|
|
36357
37458
|
return;
|
|
36358
37459
|
}
|
|
37460
|
+
// Set up the cancel handle BEFORE flipping the pending flag so a
|
|
37461
|
+
// race between the flag-set and a synchronous Esc keystroke can't
|
|
37462
|
+
// leave the input handler dispatching cancel without a ref to
|
|
37463
|
+
// mutate. The cancel callback no-ops cleanly when the ref is null
|
|
37464
|
+
// (call already settled).
|
|
37465
|
+
const cancelHandle = { cancelled: false };
|
|
37466
|
+
pullRequestBodyCancelRef.current = cancelHandle;
|
|
37467
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
|
|
36359
37468
|
dispatch({
|
|
36360
37469
|
type: 'setStatus',
|
|
36361
|
-
value: `generating PR body from changelog (vs ${defaultBranch})
|
|
37470
|
+
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to cancel`,
|
|
36362
37471
|
loading: true,
|
|
36363
37472
|
});
|
|
36364
|
-
|
|
36365
|
-
|
|
36366
|
-
|
|
36367
|
-
|
|
36368
|
-
|
|
36369
|
-
|
|
36370
|
-
|
|
36371
|
-
|
|
36372
|
-
|
|
36373
|
-
|
|
37473
|
+
try {
|
|
37474
|
+
const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
|
|
37475
|
+
// Soft-cancel check (#881 phase 4). If the user pressed Esc
|
|
37476
|
+
// while the workflow was awaiting, skip opening the prompt and
|
|
37477
|
+
// surface a neutral status. The underlying LLM call has
|
|
37478
|
+
// already settled — its result is discarded. Hard cancel
|
|
37479
|
+
// (aborting the HTTP request mid-flight) is a follow-up.
|
|
37480
|
+
if (cancelHandle.cancelled) {
|
|
37481
|
+
dispatch({ type: 'setStatus', value: 'PR draft cancelled.' });
|
|
37482
|
+
return;
|
|
37483
|
+
}
|
|
37484
|
+
// Fallback shape when the changelog generation fails — open the
|
|
37485
|
+
// prompt with empty title + body rather than aborting, so the user
|
|
37486
|
+
// can still author the PR manually. The status line surfaces why
|
|
37487
|
+
// we couldn't pre-fill.
|
|
37488
|
+
const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
|
|
37489
|
+
const initialBody = body.body || '';
|
|
37490
|
+
const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
|
|
37491
|
+
if (!body.ok) {
|
|
37492
|
+
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
|
|
37493
|
+
}
|
|
37494
|
+
else {
|
|
37495
|
+
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
37496
|
+
}
|
|
37497
|
+
dispatch({
|
|
37498
|
+
type: 'openInputPrompt',
|
|
37499
|
+
kind: 'create-pr',
|
|
37500
|
+
label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
|
|
37501
|
+
initial,
|
|
37502
|
+
multiline: true,
|
|
37503
|
+
});
|
|
36374
37504
|
}
|
|
36375
|
-
|
|
36376
|
-
|
|
37505
|
+
finally {
|
|
37506
|
+
// Clear the flag + the ref so a subsequent draft starts clean.
|
|
37507
|
+
// Only clear the ref if we still own it — a second invocation
|
|
37508
|
+
// would have already taken ownership in which case the cancel
|
|
37509
|
+
// duty has rolled over.
|
|
37510
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37511
|
+
if (pullRequestBodyCancelRef.current === cancelHandle) {
|
|
37512
|
+
pullRequestBodyCancelRef.current = null;
|
|
37513
|
+
}
|
|
36377
37514
|
}
|
|
36378
|
-
dispatch({
|
|
36379
|
-
type: 'openInputPrompt',
|
|
36380
|
-
kind: 'create-pr',
|
|
36381
|
-
label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
|
|
36382
|
-
initial,
|
|
36383
|
-
multiline: true,
|
|
36384
|
-
});
|
|
36385
37515
|
}, [
|
|
36386
37516
|
context.branches?.currentBranch,
|
|
36387
37517
|
context.provider?.currentBranch,
|
|
@@ -36390,6 +37520,24 @@ function LogInkApp(deps) {
|
|
|
36390
37520
|
context.pullRequest?.currentPullRequest,
|
|
36391
37521
|
dispatch,
|
|
36392
37522
|
]);
|
|
37523
|
+
/**
|
|
37524
|
+
* Soft-cancel the in-flight PR body draft (#881 phase 4). The
|
|
37525
|
+
* cancel ref's `.cancelled` flag is checked after the workflow's
|
|
37526
|
+
* await resolves; setting it true causes the workflow to skip the
|
|
37527
|
+
* prompt-open and surface a neutral "cancelled" status. The LLM
|
|
37528
|
+
* call itself isn't aborted (no signal threaded through the
|
|
37529
|
+
* `changelogHandler` chain) so the user still pays for the in-flight
|
|
37530
|
+
* tokens. Acceptable for a 5-15s draft; hard cancel lands in a
|
|
37531
|
+
* follow-up if it becomes a real ask.
|
|
37532
|
+
*
|
|
37533
|
+
* Idempotent — calling without an active draft is a no-op.
|
|
37534
|
+
*/
|
|
37535
|
+
const cancelPullRequestBodyDraft = React.useCallback(() => {
|
|
37536
|
+
const handle = pullRequestBodyCancelRef.current;
|
|
37537
|
+
if (!handle)
|
|
37538
|
+
return;
|
|
37539
|
+
handle.cancelled = true;
|
|
37540
|
+
}, []);
|
|
36393
37541
|
// Copy an arbitrary string to the system clipboard. Distinct from
|
|
36394
37542
|
// `yankFromActiveView` which derives the value from the current view
|
|
36395
37543
|
// — this one takes the value as an explicit event payload, used by
|
|
@@ -36815,11 +37963,18 @@ function LogInkApp(deps) {
|
|
|
36815
37963
|
type: 'setSplitPlanReady',
|
|
36816
37964
|
plan: result.plan,
|
|
36817
37965
|
planContext: result.planContext,
|
|
37966
|
+
fallback: result.fallback,
|
|
36818
37967
|
});
|
|
37968
|
+
const readyMessage = result.fallback
|
|
37969
|
+
? `Split planner exhausted retries — showing single-commit fallback. y/Enter to apply as one commit, r to re-roll, Esc to cancel.`
|
|
37970
|
+
: `Split plan ready: ${result.plan.groups.length} commit(s). y/Enter to apply, Esc to cancel.`;
|
|
37971
|
+
// Use 'info' kind for the fallback path (still actionable, just
|
|
37972
|
+
// not a clean win). The reducer's "warning" is the absence of
|
|
37973
|
+
// `success` framing — the message text itself carries the cue.
|
|
36819
37974
|
dispatch({
|
|
36820
37975
|
type: 'setStatus',
|
|
36821
|
-
value:
|
|
36822
|
-
kind: 'success',
|
|
37976
|
+
value: readyMessage,
|
|
37977
|
+
kind: result.fallback ? 'info' : 'success',
|
|
36823
37978
|
});
|
|
36824
37979
|
}, [context.operation, context.worktree?.stagedCount, dispatch, git]);
|
|
36825
37980
|
// `y`/Enter inside the overlay — apply the previewed plan. Uses the
|
|
@@ -36861,6 +38016,7 @@ function LogInkApp(deps) {
|
|
|
36861
38016
|
plan: splitPlan.plan,
|
|
36862
38017
|
planContext: splitPlan.planContext,
|
|
36863
38018
|
git,
|
|
38019
|
+
fallback: splitPlan.fallback,
|
|
36864
38020
|
});
|
|
36865
38021
|
dump.push(`workflow returned: ok=${result.ok} message="${result.message}" commitHashes=[${(result.commitHashes || []).join(', ')}]`);
|
|
36866
38022
|
try {
|
|
@@ -36894,16 +38050,20 @@ function LogInkApp(deps) {
|
|
|
36894
38050
|
return;
|
|
36895
38051
|
}
|
|
36896
38052
|
// Success — close the overlay, reset compose (the staged set is
|
|
36897
|
-
// now empty since the plan committed everything), and
|
|
36898
|
-
//
|
|
36899
|
-
//
|
|
38053
|
+
// now empty since the plan committed everything), and route the
|
|
38054
|
+
// user to the history view so they see the just-landed commits
|
|
38055
|
+
// with the recent-commit marker firing on each row that was
|
|
38056
|
+
// created. Previous behavior popped compose to whatever was
|
|
38057
|
+
// beneath (often status — which now reads "clean worktree" and
|
|
38058
|
+
// gives the user no signal that anything just happened);
|
|
38059
|
+
// history is the natural follow-on surface.
|
|
38060
|
+
//
|
|
38061
|
+
// navigateHome nukes the rest of the stack so `<` after apply
|
|
38062
|
+
// doesn't walk back into the now-empty compose / status state
|
|
38063
|
+
// the user just left behind.
|
|
36900
38064
|
dispatch({ type: 'clearSplitPlan' });
|
|
36901
38065
|
dispatch({ type: 'commitCompose', action: { type: 'reset' } });
|
|
36902
|
-
|
|
36903
|
-
// invoked from a deeper stack and we don't want to over-pop.
|
|
36904
|
-
if (state.activeView === 'compose' && state.viewStack.length > 1) {
|
|
36905
|
-
dispatch({ type: 'popView' });
|
|
36906
|
-
}
|
|
38066
|
+
dispatch({ type: 'navigateHome' });
|
|
36907
38067
|
// Refresh BEFORE setting the final status so we can peek at the
|
|
36908
38068
|
// post-apply worktree state and craft a directive next-step hint
|
|
36909
38069
|
// ("X unstaged + Y untracked remaining — press gs to stage / I
|
|
@@ -36951,9 +38111,16 @@ function LogInkApp(deps) {
|
|
|
36951
38111
|
});
|
|
36952
38112
|
return;
|
|
36953
38113
|
}
|
|
36954
|
-
const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
|
|
36955
|
-
|
|
36956
|
-
|
|
38114
|
+
const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked, result.fallback ? { reason: result.fallback.reason } : undefined);
|
|
38115
|
+
// Fallback path uses 'info' kind — apply technically succeeded
|
|
38116
|
+
// but the user should know it landed as a single combined commit
|
|
38117
|
+
// rather than a real LLM-driven multi-group split.
|
|
38118
|
+
dispatch({
|
|
38119
|
+
type: 'setStatus',
|
|
38120
|
+
value: successMessage,
|
|
38121
|
+
kind: result.fallback ? 'info' : 'success',
|
|
38122
|
+
});
|
|
38123
|
+
}, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
|
|
36957
38124
|
// Esc inside the overlay — close without applying. Status line gets
|
|
36958
38125
|
// a confirmation so the user knows the operation was abandoned.
|
|
36959
38126
|
const cancelCommitSplit = React.useCallback(() => {
|
|
@@ -37460,6 +38627,41 @@ function LogInkApp(deps) {
|
|
|
37460
38627
|
'fetch-remotes': async () => fetchRemotes(git),
|
|
37461
38628
|
'pull-current-branch': async () => pullCurrentBranch(git),
|
|
37462
38629
|
'push-current-branch': async () => pushCurrentBranch(git),
|
|
38630
|
+
// Per-branch fetch / pull / push that operate on the cursored
|
|
38631
|
+
// row in the branches sidebar. inkInput.ts dispatches these
|
|
38632
|
+
// when F / U / P fire from the sidebar; the *-current-branch
|
|
38633
|
+
// / fetch-remotes variants above still handle the same keys
|
|
38634
|
+
// from any other context.
|
|
38635
|
+
'fetch-selected-branch': async () => {
|
|
38636
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
38637
|
+
const visible = state.filter
|
|
38638
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
38639
|
+
: all;
|
|
38640
|
+
const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
|
|
38641
|
+
if (!branch)
|
|
38642
|
+
return { ok: false, message: 'No branch selected' };
|
|
38643
|
+
return fetchBranch(git, branch);
|
|
38644
|
+
},
|
|
38645
|
+
'pull-selected-branch': async () => {
|
|
38646
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
38647
|
+
const visible = state.filter
|
|
38648
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
38649
|
+
: all;
|
|
38650
|
+
const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
|
|
38651
|
+
if (!branch)
|
|
38652
|
+
return { ok: false, message: 'No branch selected' };
|
|
38653
|
+
return pullBranch(git, branch, context.branches?.currentBranch);
|
|
38654
|
+
},
|
|
38655
|
+
'push-selected-branch': async () => {
|
|
38656
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
38657
|
+
const visible = state.filter
|
|
38658
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
38659
|
+
: all;
|
|
38660
|
+
const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
|
|
38661
|
+
if (!branch)
|
|
38662
|
+
return { ok: false, message: 'No branch selected' };
|
|
38663
|
+
return pushBranch(git, branch);
|
|
38664
|
+
},
|
|
37463
38665
|
'rename-branch': async () => {
|
|
37464
38666
|
const newName = payload?.trim();
|
|
37465
38667
|
if (!newName)
|
|
@@ -38377,9 +39579,15 @@ function LogInkApp(deps) {
|
|
|
38377
39579
|
else if (event.type === 'runAiCommitDraft') {
|
|
38378
39580
|
void runAiCommitDraft();
|
|
38379
39581
|
}
|
|
39582
|
+
else if (event.type === 'cancelAiCommitDraft') {
|
|
39583
|
+
cancelAiCommitDraft();
|
|
39584
|
+
}
|
|
38380
39585
|
else if (event.type === 'startCreatePullRequest') {
|
|
38381
39586
|
void startCreatePullRequest();
|
|
38382
39587
|
}
|
|
39588
|
+
else if (event.type === 'cancelPullRequestBodyDraft') {
|
|
39589
|
+
cancelPullRequestBodyDraft();
|
|
39590
|
+
}
|
|
38383
39591
|
else if (event.type === 'startChangelogView') {
|
|
38384
39592
|
void startChangelogView();
|
|
38385
39593
|
}
|